Compare commits

...
17 Commits
Author SHA1 Message Date
nitesh 0976bc230c Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-10-07 10:19:26 +05:30
keshav 6b0f218b70 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-10-07 10:13:37 +05:30
keshav 6226682b36 feat: updates for cashier, tds reports, rbi reserve and dashboard 2026-10-07 10:13:28 +05:30
niteshandClaude Opus 5 38884610d2 feat(shares): preview the approval checklist before submit, fix settlement GL option
The Clerk Dashboard's standalone Share Withdrawal Request form never offered a
GL settlement destination -- it always submitted SAVINGS, silently, while the
Cashier's Transaction Entry screen already let a clerk pick any postable GL
account on its CR row. Added the same "Settlement Destination" choice (Savings
/ GL Account) with a searchable GL selector here too, so both entry points to
the same backend endpoint behave the same way.

Both share-withdrawal confirm dialogs (savings settlement and GL settlement, in
transaction-entry.component.ts) now call the backend's new preview-validate
endpoint before showing the confirm dialog, so a clerk sees the same checklist
a manager will see on approval -- a missing GL mapping, an unverified KYC, an
open AML alert -- before committing to the request, not after a manager has
already spent time reviewing it. A failing check is shown, not blocked: real
hard rules are still enforced server-side at approve(). Guarded with a 5s
timeout and a fallback message so a slow or unavailable check can never leave
the dialog looking stuck.

error-handler.interceptor.ts: a document/signature attachment whose row
survives but whose file is missing from the content store now resolves to a
silent 404 (matching the existing image-fetch handling) instead of surfacing a
raw error. Background GET failures (500/501/503) on calls a component already
recovers from (photo, attachment, notes, standing instructions, ...) now
surface as a corner toast instead of a blocking modal that has to be dismissed
-- a write action's failure (POST/PUT/DELETE) still gets the centred,
must-acknowledge dialog, since that is the one thing the user was waiting on.
Also surfaces the backend's real message on a 500 instead of a hardcoded
generic sentence.

Backend counterpart: ClerkShareWithdrawalRequestApiResource (preview-validate
endpoint, shared buildShareWithdrawalChecks()) and
DocumentReadPlatformServiceImpl (missing-file 404) land separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 22:36:15 +05:30
niteshandClaude Sonnet 5 d6d7d912c0 fix(savings-product): guard against duplicate submission on create
The product name is unique in the database, so a repeat click on Submit
while the first request is still in flight fails with a duplicate key
error even though the first request already created the product. Disables
the button and ignores repeat calls until the request settles; also drops
a stray debug console.log from the submit payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 12:39:43 +05:30
nitesh 454988d803 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-06 11:01:00 +05:30
niteshandClaude Sonnet 5 17bfe4c7e6 fix(tasks): separate Pending Share Accounts from the Pending Account screen
Removes the Pending Share Accounts section that had been bolted onto the
Pending Account screen (duplicate data/actions alongside the normal-account
table), restoring it as its own standalone queue. The standalone
pending-share-accounts route/component already existed but had no tab link
anywhere in the Approval Center nav, so it was unreachable from the UI -
added a "Pending Share Accounts" tab for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 10:56:25 +05:30
keshav 5ab4e9eaf0 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-05 12:41:38 +05:30
keshav 4fc6784695 feat: interest posting 2026-10-05 12:39:14 +05:30
nitesh 174a2e7b6b fix(accounting,deposits): allow future dates in premature close and GL search, fix FD/RD accounting mappings 2026-10-04 16:02:15 +05:30
nitesh 9f7d9d28fd feat: Fixed Deposit - premature close, payment dialog, account actions, translations and env updates 2026-10-04 09:12:06 +05:30
keshav 1ffac4b1e5 fix(savings-product): move savings interest paid to expenses mapping 2026-10-01 14:01:24 +05:30
niteshandClaude Sonnet 5 4dee1db360 Merge remote-tracking branch 'origin/dev-latest' into dev-latest
Brings in partner's RBI Reserve / Form 1 & 9 returns UI and interest
posting reports, alongside today's FD premature-closure work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:22:39 +05:30
niteshandClaude Sonnet 5 0c6ebfdc1a Merge branch 'feature/bugfix' into dev-latest
Brings in partner's "Enforce same-user maker-checker restriction" and
related client-KYC-step / product-edit-diff-dialog work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:21:19 +05:30
keshavandClaude Opus 5.5 b6994c0b76 feat(rbi-reserve,savings): Form 1/Form 9 returns UI, branch-wise interest posting reports and summary
- Reports > Interest Posting Reports: per branch and product, generate/regenerate
  at any time, approve; branch column, HO-on-behalf marker, posted txn per account
- Reports > Interest Posting Summary (branch-wise) for the head office, with Excel export
- Day Close warns and disables Close Day while the branch's interest report is unapproved
- Savings product accounting: Interest On Savings (expense), Savings Interest Payable
  and Paid (liability rows, required)
- RBI Form 1 statutory and Form 9 return screens, reserve config updates
- Interest posting report GETs exempted from the response cache

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:20:01 +05:30
niteshandClaude Sonnet 5 4f02f24aae feat(deposits): RBI premature closure compliance UI, renewal-history status fix
Premature Close dialog: adds the Closure Reason dropdown, the explicit
Waive Penalty checkbox, a tenure/date preview panel, and an "Effective
Rate for this closure" display so the maker sees exactly what rate and
penalty treatment is about to apply.

Renewal History tab: the latest term's card kept showing an ACTIVE badge
even after the account was prematurely closed, since the archived
renewal-status column only ever updates on an actual renewal rollover.
For the last card only, status now cross-checks the account's own live
status instead. "Projected Maturity" now reads "Closed At" once that
term has prematurely closed, rather than implying it's still a projection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:19:37 +05:30
niteshandClaude Sonnet 5 74a51a0626 fix(products): show named GL account changes in the pending-edit diff dialog
otherGlMappings (the nested document behind Savings/Current/FD/RD Interest
Payable/Paid and similar named accounts) was excluded from the generic
field diff entirely, and the backend was silently dropping its value
anyway (see the paired backend fix). Now that the backend correctly
stages this field, diff it explicitly: find each reserved account name
across original.otherGlMappingsMap and proposed.otherGlMappings, resolve
the GL code to a readable name, and show a row only when it actually
moved -- same convention as every other field in this dialog, instead of
a raw JSON blob or nothing at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:40:10 +05:30
111 changed files with 9921 additions and 2192 deletions
@@ -279,7 +279,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [max]="maxDate" [formControl]="transactionDateFrom" />
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [formControl]="transactionDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
<mat-datepicker #dpFrom></mat-datepicker>
</mat-form-field>
@@ -288,7 +288,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpTo" [min]="minDate" [max]="maxDate" [formControl]="transactionDateTo" />
<input matInput [matDatepicker]="dpTo" [min]="minDate" [formControl]="transactionDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
<mat-datepicker #dpTo></mat-datepicker>
</mat-form-field>
@@ -353,13 +353,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input
matInput
[matDatepicker]="dpSubFrom"
[min]="minDate"
[max]="maxDate"
[formControl]="submittedOnDateFrom"
/>
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
<mat-datepicker #dpSubFrom></mat-datepicker>
</mat-form-field>
@@ -368,7 +362,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [max]="maxDate" [formControl]="submittedOnDateTo" />
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [formControl]="submittedOnDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
<mat-datepicker #dpSubTo></mat-datepicker>
</mat-form-field>
@@ -90,7 +90,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
maxDate = new Date();
maxDate: Date = null;
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.maxDate = null;
this.paginator.pageSize = 10;
this.restoreState();
this.setFilteredOffices();
@@ -17,7 +17,12 @@
</p>
</div>
</div>
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
<button
class="bor-btn bor-btn--xls"
type="button"
[disabled]="exporting || !filtered.length"
(click)="exportExcel()"
>
<fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Exporting…' : 'Export' }}
</button>
@@ -132,7 +137,9 @@
<td class="num">{{ r.approvedShares | number }}</td>
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
<td class="num">{{ r.trancheCount }}</td>
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
<td>
<span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span>
</td>
<td class="mono">{{ r.activatedDate || '—' }}</td>
</tr>
} @empty {
@@ -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')}`;
}
@@ -107,11 +107,26 @@
</span>
</div>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="startClose(openDay)"
[disabled]="interestPending.length > 0"
>
<mat-icon>lock</mat-icon> Close Day
</button>
}
</div>
@if (interestPending.length > 0) {
<div class="bd-interest-alert">
<mat-icon>warning</mat-icon>
<span>
Savings interest posting report is not approved for <strong>{{ interestPending.join(', ') }}</strong
>. Approve it in <a routerLink="/reports/interest-posting">Reports → Interest Posting Reports</a> before
closing the day.
</span>
</div>
}
} @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
@@ -372,3 +372,30 @@
color: #d97706;
}
}
// Shown under the open day while a savings interest posting report due today is unapproved.
.bd-interest-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 8px 0 12px;
padding: 10px 14px;
border: 1px solid #f59e0b;
border-radius: 8px;
background: #fef3c7;
color: #92400e;
font-size: 13px;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
}
a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
}
@@ -189,6 +189,23 @@ export class BranchDayComponent implements OnInit {
return this.totalOf(this.closeCounts);
}
/**
* Savings products whose interest posting report is due today and not yet approved. The close is
* refused by the backend while any is pending; this only says so before the user tries.
*/
interestPending: string[] = [];
private checkInterestReports(): void {
if (!this.openDay?.businessDate) {
this.interestPending = [];
return;
}
this.service
.interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res) => (this.interestPending = res?.pendingProducts ?? []));
}
load(): void {
this.loading = true;
this.errorMsg = '';
@@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit {
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
this.checkInterestReports();
});
this.service
@@ -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')}`;
@@ -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) => {
@@ -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()
@@ -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) => {
@@ -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();
+17
View File
@@ -205,6 +205,23 @@ export class CashierService {
return this.http.get('/branchday', { params });
}
/**
* The savings products whose interest posting report is due for the date and not yet approved —
* the backend refuses to close any branch's day while one is pending.
*/
interestDayCloseCheck(
date?: string,
officeId?: number
): Observable<{ businessDate: string; canClose: boolean; pendingProducts: string[] }> {
let params = new HttpParams();
if (date) params = params.set('date', date);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<{ businessDate: string; canClose: boolean; pendingProducts: string[] }>(
'/savingsinterestpostingreports/day-close-check',
{ params }
);
}
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
@@ -51,6 +51,12 @@ export class ClerkShareWithdrawalService {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
}
/** Runs the exact same checklist approve() will run, against a draft that hasn't been submitted
* yet — so the clerk sees it in the confirm dialog, before committing to the request. */
previewValidate(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests/preview-validate', data);
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
}
@@ -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: () => {} });
@@ -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')}`;
}
@@ -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) => {
+2 -1
View File
@@ -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')}`;
}
}
@@ -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()
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
@@ -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
@@ -3425,6 +3426,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementMode: 'SAVINGS',
settlementRowSnapshot
};
@@ -3455,11 +3457,60 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
/**
* Shows the confirm dialog with the same checklist the Manager will see when approving this
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
* so it can never show the clerk something different from what the approver actually sees. A
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
* still fix them, before a manager's time is spent on a request that was always going to fail.
*/
private showShareWithdrawalConfirm(
payload: Record<string, unknown>,
baseLines: string[],
onConfirm: () => void
): void {
this.clerkShareWithdrawalService
.previewValidate(payload)
.pipe(
// A request that never resolves (slow network, server busy) must not leave the Submit
// button looking dead — the dialog still has to open, just without the checklist.
timeout(5000),
catchError(() => of({ __previewFailed: true }))
)
.subscribe((res: any) => {
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
// says so explicitly rather than just omitting the section — a silently empty checklist
// looks identical to "everything passed, nothing to show", which is the one reading this
// must never give.
const checkLines = res?.__previewFailed ? [
'',
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
] : checks.length ? [
'',
'Validation Checks (same as Manager approval):',
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
] : [];
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
// sentence, which must stay last so the modal renders it as a footnote rather than a
// key/value row — so the checklist is spliced in just before that pair, not appended after.
const footerStart =
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
const lines = [
...baseLines.slice(0, footerStart),
...checkLines,
...baseLines.slice(footerStart)
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
});
}
/**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
@@ -3512,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
@@ -3571,7 +3622,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
@@ -4173,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')}`;
})();
@@ -4549,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')}`;
}
@@ -5071,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);
@@ -5959,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 = {
@@ -6154,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);
@@ -6259,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
@@ -6370,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')}`;
@@ -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
@@ -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')}`;
}
@@ -129,6 +129,31 @@
<span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Settlement Destination</span>
@if (!isReview) {
<select [ngModel]="settlementMode" (ngModelChange)="onSettlementModeChange($event)">
<option value="SAVINGS">Member's Linked Savings Account</option>
<option value="GL">GL Account</option>
</select>
} @else {
<input
type="text"
[value]="reviewRequest?.settlement_mode === 'GL' ? 'GL Account' : 'Linked Savings Account'"
readonly
/>
}
</label>
@if (!isReview && settlementMode === 'GL') {
<label class="inv-form-field">
<span>* Settlement GL Account</span>
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
></mifosx-gl-account-selector>
</label>
}
<label class="inv-form-field">
<span>* Settlement Payment Type</span>
@if (!isReview) {
@@ -386,14 +411,25 @@
<td>Date of Meeting</td>
<td>{{ meetingDate }}</td>
</tr>
<tr>
<td>Settlement Destination</td>
<td>{{ settlementMode === 'GL' ? 'GL Account' : 'Linked Savings Account' }}</td>
</tr>
@if (settlementMode === 'GL') {
<tr>
<td>Settlement GL Account</td>
<td>{{ selectedSettlementGlName }}</td>
</tr>
} @else {
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
}
<tr>
<td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td>
</tr>
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
</tbody>
</table>
@@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FormsModule, UntypedFormControl } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
@@ -19,6 +19,9 @@ import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { AccountingService } from '../../accounting/accounting.service';
import { GLAccount } from '../../shared/models/general.model';
export type ShareWdMode = 'create' | 'review';
@@ -51,7 +54,8 @@ interface ActivityEntry {
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent
SearchSelectComponent,
GlAccountSelectorComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
@@ -60,6 +64,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService);
private accountingService = inject(AccountingService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
@@ -91,6 +96,17 @@ export class ShareWithdrawalRequestComponent implements OnInit {
paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null;
/** Where the redeemed value goes. 'SAVINGS' (the long-standing default) credits the member's
* linked savings account. 'GL' lets the clerk pick any postable GL account directly — same
* option the Cashier's Transaction Entry screen already offers on its CR row, now available
* here too so both entry points behave the same way. Naming the product's own Share Sundry GL
* here is still fine: the backend recognises it and books straight to Sundry with no further
* settlement leg (see ClerkShareWithdrawalRequestApiResource#submit). */
settlementMode: 'SAVINGS' | 'GL' = 'SAVINGS';
settlementGlAccountId: number | null = null;
glAccountsData: GLAccount[] = [];
toGlAccountControl = new UntypedFormControl('');
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
@@ -132,6 +148,17 @@ export class ShareWithdrawalRequestComponent implements OnInit {
return this.selectedSettlementPaymentType?.name || '—';
}
/** The chosen GL account's display name, for the confirm dialog and the checklist. Falls back to
* the raw id while the GL account list is still loading. */
get selectedSettlementGlName(): string {
if (!this.settlementGlAccountId) {
return '—';
}
return (
this.glAccountsData.find((g) => g.id === this.settlementGlAccountId)?.name || `GL #${this.settlementGlAccountId}`
);
}
get isFormValid(): boolean {
if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false;
@@ -141,6 +168,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false;
if (this.settlementMode === 'GL' && !this.settlementGlAccountId) return false;
return true;
}
@@ -160,11 +188,18 @@ export class ShareWithdrawalRequestComponent implements OnInit {
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
{
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
this.settlementMode === 'GL'
? {
label: 'Settlement GL Account selected',
pass: !!this.settlementGlAccountId,
detail: this.settlementGlAccountId ? this.selectedSettlementGlName : 'Missing'
}
: {
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail:
this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—',
@@ -214,11 +249,37 @@ export class ShareWithdrawalRequestComponent implements OnInit {
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
this.toGlAccountControl.valueChanges.subscribe((id: any) => {
this.settlementGlAccountId = id ? Number(id) : null;
});
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
/** Flips the settlement destination. Switching away from GL clears whatever was picked so a
* stale GL id can never ride along on a SAVINGS submission. */
onSettlementModeChange(mode: 'SAVINGS' | 'GL'): void {
this.settlementMode = mode;
if (mode === 'GL') {
this.loadGlAccounts();
} else {
this.settlementGlAccountId = null;
this.toGlAccountControl.setValue('');
}
}
/** Fetched on first use rather than with the page, since most withdrawals settle to savings. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
@@ -246,6 +307,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? '';
this.settlementMode = req.settlement_mode === 'GL' ? 'GL' : 'SAVINGS';
this.settlementGlAccountId = req.settlement_gl_account_id ? Number(req.settlement_gl_account_id) : null;
if (this.settlementMode === 'GL') {
this.loadGlAccounts();
}
if (req.activity_log) {
try {
@@ -289,7 +355,9 @@ export class ShareWithdrawalRequestComponent implements OnInit {
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId
settlementPaymentTypeId: this.settlementPaymentTypeId,
settlementMode: this.settlementMode,
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
};
this.wdService.submit(payload).subscribe({
next: () => {
@@ -56,8 +56,8 @@
<mat-icon>info_outline</mat-icon>
<div>
@if (configMissing) {
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under
Organization › TDS Configuration.
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under Organization
› TDS Configuration.
} @else if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab.
@@ -116,8 +116,8 @@
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
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.
</div>
</div>
}
@@ -192,20 +192,90 @@
}
</span>
</div>
@if (declarationOnRecord && declarationFy) {
@if (declarationRecord; as d) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ declarationFy }}</span>
<span>FY {{ d.fy }} · Form {{ d.formType }}</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<div class="f121__kv-row">
<span>Accepted on</span>
<span>
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.
</span>
</div>
@if (d.uin) {
<div class="f121__kv-row">
<span>UIN</span>
<span class="f121__mono">{{ d.uin }}</span>
</div>
}
<div class="f121__kv-row">
<span></span>
<span>
<button type="button" class="f121__btn f121__btn--warn" (click)="revokeDeclaration()">
Withdraw declaration
</button>
</span>
</div>
} @else if (fy && !configMissing) {
<div class="f121__kv-row">
<span></span>
<span>
@if (!showDeclarationForm) {
<button type="button" class="f121__btn" (click)="openDeclarationForm()" [disabled]="!panWellFormed">
Record {{ formLabel }} for FY {{ fy }}
</button>
@if (!panWellFormed) {
<div class="muted">A valid PAN must be on the KYC first — a declaration without one is void.</div>
}
}
</span>
</div>
@if (showDeclarationForm) {
<div class="f121__decl-form">
<label>
<span>Accepted on *</span>
<input type="date" [(ngModel)]="newDeclaration.acceptedOn" />
</label>
<label>
<span>UIN</span>
<input type="text" [(ngModel)]="newDeclaration.uin" placeholder="Allotted on acceptance" />
</label>
<label>
<span>Estimated income for the year (₹)</span>
<input type="number" min="0" [(ngModel)]="newDeclaration.estimatedIncome" />
</label>
@if (declarationError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>{{ declarationError }}</div>
</div>
}
<div class="f121__decl-actions">
<button type="button" class="f121__btn" (click)="showDeclarationForm = false">Cancel</button>
<button
type="button"
class="f121__btn f121__btn--primary"
(click)="saveDeclaration()"
[disabled]="savingDeclaration"
>
{{ savingDeclaration ? 'Saving…' : 'Save declaration' }}
</button>
</div>
</div>
}
}
@for (old of declarations; track old.id) {
@if (old.revoked && old.fy === fy) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Withdrawn</span>
<span>
Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn
{{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }}
</span>
</div>
}
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
@@ -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;
}
@@ -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<void>();
@@ -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<string, number>();
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<string, TdsDeductee | null>();
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<string, number>();
this.platformByFy = new Map<string, TdsDeductee | null>();
const fys = new Set<string>();
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);
}
/**
@@ -477,7 +477,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
adverseMediaMatch: !!v.adverseMediaMatch,
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
// customer who declared no income at all.
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualIncome:
v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
};
@@ -522,7 +523,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -769,7 +770,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
@@ -930,7 +931,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
+19 -1
View File
@@ -31,7 +31,25 @@ export class CacheInterceptor implements HttpInterceptor {
* underlying GL mapping changed — the request was answered from cachedData and the backend was
* never hit again. Matched as a substring of the request URL.
*/
private static readonly NEVER_CACHE = ['/rbireservereturn', '/rbireserveconfig', '/tdsconfig', '/tds/deposit-interest'];
private static readonly NEVER_CACHE = [
'/rbireservereturn',
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
// Declarations and challans are entered on the very screens that read them back.
'/tds/declarations',
'/tds/challans',
'/tds/deducted',
'/savingsinterestpostingreports',
// Cash in vaults and drawers changes with every transaction; a cached teller list or position
// kept showing a branch's old tellers and figures at day close.
'/tellers',
'/cashreports',
'/cashreconciliation',
'/cashsettlement',
'/cashtransfer',
'/branchday'
];
private isCacheable(url: string): boolean {
return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment));
+27 -5
View File
@@ -67,12 +67,18 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
// endpoint isn't available on a given backend deployment, the client-general-step
// component already falls back to riskRules = [] silently; no toast needed here either.
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
// A document's own file can be missing from the content store (a database copied between
// environments without its file store is the usual cause) even though its m_document row
// survives — the backend reports that as a 404, same as a client with no photo, and every
// caller that fetches a signature/attachment image already recovers with its own catchError.
const isDocumentAttachmentRequest = request.url.match(/\/documents\/\d+\/attachment$/);
const isSilentRequest =
isClientImageRequest ||
isClientKycRequest ||
isDirectorRequest ||
isClientPendingEditRequest ||
isKycRiskRulesRequest;
isKycRiskRulesRequest ||
isDocumentAttachmentRequest;
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
// component already recovers via its own catchError) — silenced for every status, since
@@ -93,8 +99,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
throw response;
}
// Resolved once and emitted in one place below, so every failure here is consistently tagged
// as an error and therefore surfaces as a centred modal rather than a corner toast.
// Resolved once and emitted in one place below — see the severity decision there for which of
// these surfaces as a centred modal versus a corner toast.
let alert: { type: string; message: string } | null = null;
if (status === 401 || (environment.oauth.enabled && status === 400)) {
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
@@ -119,7 +125,13 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
message: errorMessage || 'This action is not allowed in the current state.'
};
} else if (status === 500) {
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
// errorMessage carries the backend's actual explanation (defaultUserMessage/developerMessage)
// when one is present; the generic sentence is only a last resort for a response with no body
// at all (a proxy timeout, a stack trace the server never wrapped into JSON).
alert = {
type: 'Internal Server Error',
message: errorMessage || 'Internal Server Error. Please try again later.'
};
} else if (status === 501) {
alert = {
type: this.translate.instant('error.resource.notImplemented.type'),
@@ -133,7 +145,17 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
}
if (alert) {
this.alertService.alert({ ...alert, severity: 'error' });
// A GET is the page reading data it did not ask the user for, in the background — a photo,
// an attachment, a notes list, a standing-instructions panel. Every one of those already has
// its own catchError fallback at the call site (see transaction-entry.component.ts and
// similar account-overview loaders), so the page keeps working regardless. Blocking the whole
// screen on a dialog the user has to click "OK" on, for data that was never more than a
// secondary panel, is the "irritate na ho" complaint this guards against — it goes to the
// corner toast instead, same place a non-error alert already goes. A write (POST/PUT/PATCH/
// DELETE) is different: that is the one action the user was waiting on, so its failure still
// needs the centred, must-acknowledge dialog.
const isBackgroundRead = request.method === 'GET' && (status === 500 || status === 501 || status === 503);
this.alertService.alert({ ...alert, severity: isBackgroundRead ? undefined : 'error' });
}
throw response;
+16 -3
View File
@@ -663,6 +663,12 @@
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
@@ -2076,6 +2082,8 @@
at month end. -->
<div class="gl-group">
<div class="gl-group-title">Reserve Returns</div>
<!-- The statutory form is a tab on Form 1 rather than an entry of its own: two menu
items for one return had a filer guessing which one they were looking at. -->
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form 1 CRR Cash Reserve Ratio')"
@@ -2084,13 +2092,18 @@
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 1 — CRR (fortnightly)
</a>
<!-- Form 9 (SLR) has no entry of its own. Section 24 is settled on the statutory Form 1,
at Part C, on the same liabilities Part B charges the cash reserve on — so the SLR
position is read there rather than on a second screen that computed it from a
differently drawn base and disagreed. -->
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form 9 SLR Statutory Liquidity Ratio')"
routerLink="/rbi/form9"
[class.hidden]="!matchesRbiSearch('Form IX R408 Return on Assets and Liabilities balance sheet')"
routerLink="/rbi/form9-return"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 9 — SLR (monthly)
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form IX — Assets &amp;
Liabilities (R408)
</a>
</div>
<!-- CIMS / XBRL Returns -->
@@ -20,7 +20,7 @@
<div class="t-live"></div>
&nbsp;LIVE
</div>
<div class="t-chip">Branch:&nbsp;<strong>HO</strong></div>
<div class="t-chip">Branch:&nbsp;<strong>{{ officeLabel }}</strong></div>
<div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div>
@@ -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<any>;
@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<boolean> = 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();
+30
View File
@@ -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')}`;
}
@@ -268,7 +268,7 @@
<span class="nx-row__label">OD Interest Charged</span>
<span class="nx-row__value nx-row__value--amount">
{{
(currentAccountData.summary.totalOverdraftInterestDerived || 0)
currentAccountData.summary.totalOverdraftInterestDerived || 0
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
@@ -24,6 +24,7 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn,
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -97,6 +98,7 @@ export class PaymentDialogComponent implements OnInit {
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
@@ -375,7 +377,16 @@ export class PaymentDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
next: () => {
next: (response: any) => {
// Parked in the Checker Inbox rather than executed -- see the same handling in
// PrematureCloseDialogComponent for why an HTTP 200 here can still mean "not applied yet".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'This payment has been submitted for approval. The deposit is unchanged until a checker approves it.'
});
}
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
@@ -51,12 +51,53 @@
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Closure Reason</mat-label>
<mat-select formControlName="prematureClosureReasonId">
@for (reason of closureReasonOptions; track reason.id) {
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loadingAmount) {
<div class="pcd__loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Calculating premature-closure amount...</span>
</div>
} @else {
@if (data.activatedOnDate || data.maturityDate || data.currentBalance !== undefined) {
<div class="pcd__preview pcd__preview--tenure">
@if (data.activatedOnDate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Current Term Started</span>
<span class="pcd__preview-value">{{ activatedOnDateDisplay }}</span>
</div>
<div class="pcd__preview-row">
<span class="pcd__preview-label">Tenure Completed (this term)</span>
<span class="pcd__preview-value"
>{{ tenureElapsedDisplay }} <span class="pcd__preview-sub">({{ daysElapsed }} days)</span></span
>
</div>
}
@if (data.maturityDate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Scheduled Maturity Date</span>
<span class="pcd__preview-value"
>{{ maturityDateDisplay }}
<span class="pcd__preview-sub">({{ daysRemainingToMaturity }} days left)</span></span
>
</div>
}
@if (data.currentBalance !== undefined && data.currentBalance !== null) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Current Balance (as of today)</span>
<span class="pcd__preview-value">{{ data.currentBalance | formatNumber }}</span>
</div>
}
</div>
}
@if (maturityAmount !== null && maturityAmount !== undefined) {
<div class="pcd__preview">
@if (data.depositAmount) {
@@ -71,12 +112,27 @@
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
</div>
}
@if (effectiveInterestRate !== null && effectiveInterestRate !== undefined) {
<div class="pcd__preview-row pcd__preview-row--highlight">
<span class="pcd__preview-label">Effective Rate for this closure</span>
<span class="pcd__preview-value">{{ effectiveInterestRate }}%</span>
</div>
}
@if (data.preClosurePenalApplicable) {
<div class="pcd__preview-row">
<div
class="pcd__preview-row"
[class.pcd__preview-row--waived]="prematureCloseForm.get('waivePreClosurePenalty').value"
>
<span class="pcd__preview-label"
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
>
<span class="pcd__preview-value">&minus; {{ data.preClosurePenalInterest }}%</span>
<span class="pcd__preview-value">
@if (prematureCloseForm.get('waivePreClosurePenalty').value) {
<span class="pcd__penalty-waived-tag">Waived</span>
} @else {
&minus; {{ data.preClosurePenalInterest }}%
}
</span>
</div>
}
@if (interestEarned !== null) {
@@ -84,7 +140,9 @@
<span class="pcd__preview-label"
>Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }})</span
>
<span class="pcd__preview-value">+ {{ interestEarned | formatNumber }}</span>
<span class="pcd__preview-value"
>{{ interestEarned >= 0 ? '+' : '' }} {{ interestEarned | formatNumber }}</span
>
</div>
}
<div class="pcd__preview-row pcd__preview-row--total">
@@ -94,6 +152,26 @@
</div>
}
@if (data.preClosurePenalApplicable) {
@if (isAutoWaivedReason) {
<div class="pcd__waive-locked">
<fa-icon icon="check-circle" class="m-r-10"></fa-icon>
<span>
Penalty automatically waived -- <strong>{{ selectedReasonLabel }}</strong> always waives the
pre-closure penalty, regardless of this checkbox.
</span>
</div>
} @else {
<mat-checkbox formControlName="waivePreClosurePenalty" class="pcd__waive-checkbox">
Waive the pre-closure penalty for this closure
<span class="pcd__waive-hint">
Interest recalculates at the full contracted rate instead of the penalty rate above. This does not
change the product's own penalty setting -- it applies to this one closure only.
</span>
</mat-checkbox>
}
}
<div class="pcd__section-label" id="pcd-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pcd__options" role="radiogroup" aria-labelledby="pcd-action-label">
@for (closureOption of onAccountClosureOptions; track closureOption) {
@@ -129,8 +207,8 @@
@if (noDestinationAccounts) {
<p class="pcd__hint pcd__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Open one first, or choose an action that
pays out in cash instead.
This customer has no active savings account to transfer into. Premature closure has to be paid into a
savings account, so open one for this customer before closing this deposit.
</p>
}
@@ -153,6 +231,16 @@
</mat-form-field>
}
@if (prematureCloseForm.contains('toGlAccountId')) {
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
[inputLabel]="'Transfer to GL Account'"
>
</mifosx-gl-account-selector>
}
@if (prematureCloseForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
@@ -204,6 +204,86 @@
font-weight: 700;
color: #0f172a;
}
&-sub {
font-family: 'DM Sans', sans-serif;
font-size: 11px;
font-weight: 500;
color: #94a3b8;
margin-left: 4px;
}
}
.pcd__preview--tenure {
margin-bottom: 14px;
}
.pcd__preview-row--waived {
.pcd__preview-label {
color: #16a34a;
}
}
.pcd__penalty-waived-tag {
font-family: 'DM Sans', sans-serif;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #15803d;
background: #dcfce7;
border: 1px solid #86efac;
border-radius: 999px;
padding: 3px 10px;
}
.pcd__waive-checkbox {
display: flex;
align-items: flex-start;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
margin: 0 0 18px;
font-size: 13px;
font-weight: 600;
color: #0f172a;
::ng-deep .mdc-form-field {
align-items: flex-start;
}
::ng-deep .mdc-checkbox {
margin-top: -9px;
}
}
.pcd__waive-locked {
display: flex;
align-items: flex-start;
gap: 10px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-left: 4px solid #16a34a;
border-radius: 10px;
color: #15803d;
font-size: 13px;
line-height: 1.5;
padding: 12px 16px;
margin: 0 0 18px;
strong {
color: #166534;
}
}
.pcd__waive-hint {
display: block;
font-size: 12px;
font-weight: 500;
color: #64748b;
margin-top: 3px;
line-height: 1.4;
}
.pcd__section-label {
@@ -23,13 +23,19 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators }
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
import { GLAccount } from 'app/shared/models/general.model';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* "Premature Close" dialog -- Active-only, no reinvest option (the deposit hasn't matured, so there's
* nothing to renew): pay out in cash, or transfer the pre-maturity balance to a savings account. Same
* nothing to renew): transfer the pre-maturity balance to a savings account, or post it to a GL account.
* There is deliberately no cash payout -- the backend does not offer or accept one for a premature
* closure, so the proceeds always stay traceable to an account. Same
* `.pcd__*` visual language as the Payment dialog ({@link PaymentDialogComponent}), in this Danger Zone
* action's own red accent rather than Payment's blue, since it's an irreversible, usually
* penalty-bearing action rather than a routine one.
@@ -49,24 +55,51 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatDialogClose,
CdkTextareaAutosize,
FaIconComponent,
FormatNumberPipe
FormatNumberPipe,
GlAccountSelectorComponent
]
})
export class PrematureCloseDialogComponent implements OnInit {
/** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */
private static readonly WITHDRAW_DEPOSIT_ID = 100;
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
private static readonly TRANSFER_TO_GL_ID = 600;
private static readonly ACTION_ICONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet'
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'book'
};
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account'
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'Post the full balance to a GL account'
};
/**
* Mirrors the backend's PrematureClosureReasonType values exactly -- see that enum for why these
* four (everything but Normal) always waive the penalty by RBI policy rather than at the maker's
* discretion. `gatedBy` is the product-facility flag (from `data`) that must be true for the option
* to even be offered; `null` means always offered. Deceased Settlement / Nominee-Legal Heir Closure
* are gated because they are the product's own opt-in facilities (see SavingsProduct
* deceasedSettlement/noPenaltyNomineeHeirClosure) -- a product that never enabled one shouldn't let
* a maker pick it to bypass a penalty that product's terms don't actually exempt.
*/
private static readonly NORMAL_REASON_ID = 100;
private static readonly ALL_CLOSURE_REASONS: {
id: number;
label: string;
autoWaived: boolean;
gatedBy: 'deceasedSettlement' | 'noPenaltyNomineeHeirClosure' | null;
}[] = [
{ id: 100, label: 'Normal Premature Closure', autoWaived: false, gatedBy: null },
{ id: 200, label: 'Premature Extension / Renewal', autoWaived: true, gatedBy: null },
{ id: 300, label: 'Conversion to Another Term Deposit', autoWaived: true, gatedBy: null },
{ id: 400, label: 'Deceased Settlement', autoWaived: true, gatedBy: 'deceasedSettlement' },
{ id: 500, label: 'Nominee / Legal Heir Closure', autoWaived: true, gatedBy: 'noPenaltyNomineeHeirClosure' }
];
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
data: {
accountId: number | string;
@@ -76,16 +109,29 @@ export class PrematureCloseDialogComponent implements OnInit {
preClosurePenalApplicable?: boolean;
preClosurePenalInterest?: number;
preClosurePenalInterestOnType?: { value: string };
deceasedSettlement?: boolean;
noPenaltyNomineeHeirClosure?: boolean;
activatedOnDate?: any;
maturityDate?: any;
currentBalance?: number;
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private accountingService = inject(AccountingService);
private alertService = inject(AlertService);
private dateUtils = inject(Dates);
prematureCloseForm: UntypedFormGroup;
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
/** Postable GL accounts, loaded once -- only read when the GL action is chosen. */
glAccountsData: GLAccount[] = [];
maturityAmount: number = null;
/** The rate actually resolved for THIS preview by the backend (actual-period vs contracted,
* penalty-deducted or not) -- distinct from data.nominalAnnualInterestRate, which is always the
* account's unmodified contracted rate. Read fresh on every recalculation. */
effectiveInterestRate: number = null;
loadingAmount = true;
loadFailed = false;
submitting = false;
@@ -99,17 +145,66 @@ export class PrematureCloseDialogComponent implements OnInit {
closedOnDate: [
this.maxDate,
Validators.required
]
],
// Both always present (not added conditionally like onAccountClosureId below) so submit()
// always carries a value, even for a product with no penalty configured at all -- the backend
// ignores them there anyway, since there is nothing to waive.
prematureClosureReasonId: [PrematureCloseDialogComponent.NORMAL_REASON_ID],
waivePreClosurePenalty: [false]
});
this.prematureCloseForm
.get('closedOnDate')
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
// Waiving/un-waiving recalculates the preview the same way changing the date does -- the maker
// should see the actual amount they're about to confirm, not the penalty-applied one, the moment
// they tick the box.
this.prematureCloseForm
.get('waivePreClosurePenalty')
.valueChanges.subscribe(() => this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value));
// Picking an RBI-mandated waiver reason forces the checkbox on (see onClosureReasonChange) and,
// like it, recalculates the preview so the amount shown always matches the reason selected.
this.prematureCloseForm
.get('prematureClosureReasonId')
.valueChanges.subscribe((id: number) => this.onClosureReasonChange(id));
// Same date the form already starts with -- without this the amount/options never load unless the
// maker happens to touch the date picker, which the full-page form this replaces required too.
this.calculatePrematureAmount(this.maxDate);
}
/** The reason list this account's product actually allows -- see ALL_CLOSURE_REASONS' `gatedBy`. */
get closureReasonOptions(): { id: number; label: string; autoWaived: boolean }[] {
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.filter(
(reason) => !reason.gatedBy || this.data[reason.gatedBy]
);
}
/** True when the selected reason waives the penalty by policy, not maker choice -- the checkbox is
* hidden in favour of a plain statement of that fact when this is true (see the template). */
get isAutoWaivedReason(): boolean {
return !!this.selectedReason?.autoWaived;
}
get selectedReasonLabel(): string {
return this.selectedReason?.label ?? '';
}
private get selectedReason(): { id: number; label: string; autoWaived: boolean } | undefined {
const id = this.prematureCloseForm?.get('prematureClosureReasonId')?.value;
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((reason) => reason.id === id);
}
private onClosureReasonChange(reasonId: number): void {
const reason = PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((r) => r.id === reasonId);
if (reason?.autoWaived) {
// emitEvent: false -- the explicit calculatePrematureAmount call below already covers this
// change; without it, this setValue would also fire waivePreClosurePenalty's own subscription
// and the amount would be calculated twice for one reason change.
this.prematureCloseForm.get('waivePreClosurePenalty').setValue(true, { emitEvent: false });
}
this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value);
}
private calculatePrematureAmount(date: Date): void {
this.loadingAmount = true;
this.loadFailed = false;
@@ -118,7 +213,9 @@ export class PrematureCloseDialogComponent implements OnInit {
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
locale
locale,
prematureClosureReasonId: this.prematureCloseForm.get('prematureClosureReasonId')?.value,
waivePreClosurePenalty: !!this.prematureCloseForm.get('waivePreClosurePenalty')?.value
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
@@ -127,6 +224,7 @@ export class PrematureCloseDialogComponent implements OnInit {
this.savingsAccountsData = response.savingsAccounts ?? [];
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
this.maturityAmount = response.maturityAmount;
this.effectiveInterestRate = response.nominalAnnualInterestRate ?? null;
this.loadingAmount = false;
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
@@ -144,19 +242,50 @@ export class PrematureCloseDialogComponent implements OnInit {
});
}
/** Adds/removes the destination-account control to match what the chosen type needs. */
/** Adds/removes the destination control to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
if (id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID) {
if (!this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
}
} else {
const toSavings = id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID;
const toGl = id === PrematureCloseDialogComponent.TRANSFER_TO_GL_ID;
if (toSavings && !this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
} else if (!toSavings) {
this.prematureCloseForm.removeControl('toSavingsAccountId');
}
if (toGl) {
if (!this.prematureCloseForm.contains('toGlAccountId')) {
this.prematureCloseForm.addControl('toGlAccountId', new UntypedFormControl('', Validators.required));
}
this.loadGlAccounts();
} else {
this.prematureCloseForm.removeControl('toGlAccountId');
}
// Both destinations carry the description through -- to the account transfer, or to the narration on
// the journal entry that credits the GL account.
if ((toSavings || toGl) && !this.prematureCloseForm.contains('transferDescription')) {
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
} else if (!toSavings && !toGl) {
this.prematureCloseForm.removeControl('transferDescription');
}
}
/** Fetched on first use rather than with the dialog, since most closures never pick the GL action. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
/** The GL selector takes a control instance, not a control name. */
get toGlAccountControl(): UntypedFormControl {
return this.prematureCloseForm.get('toGlAccountId') as UntypedFormControl;
}
/** The gap between the deposit and the computed maturity amount -- the actual interest this premature
* closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */
get interestEarned(): number | null {
@@ -166,6 +295,94 @@ export class PrematureCloseDialogComponent implements OnInit {
return Number(this.maturityAmount) - Number(this.data.depositAmount);
}
get activatedOnDateDisplay(): string {
return this.formatFullDate(this.data.activatedOnDate);
}
get maturityDateDisplay(): string {
return this.formatFullDate(this.data.maturityDate);
}
/**
* The date every "as of" figure in this dialog is measured against -- the maker's own chosen Premature
* Close Date, falling back to Fineract's configured Business Date. Deliberately NOT the real-world
* clock (`new Date()`): Fineract's business date can lag behind the calendar (a bank's own admin
* advances it), and the backend's interest calculation is measured from THIS date, not today's actual
* date. Using the real clock here previously showed a "days elapsed" figure that disagreed with the
* interest the backend actually computed for the same closure -- both must read off the same clock.
*/
private get referenceDate(): Date {
const picked = this.prematureCloseForm?.get('closedOnDate')?.value;
return picked instanceof Date ? picked : this.maxDate;
}
/** Days from the current term's start to the chosen closure date -- 0 if the date is missing or the
* closure date is somehow before it, rather than a negative. */
get daysElapsed(): number | null {
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
if (!start) {
return null;
}
return Math.max(0, this.dateUtils.calculateDiff(start, this.referenceDate));
}
/** Days from the chosen closure date to the scheduled maturity date -- 0 once matured/overdue rather
* than negative, since this dialog is for a still-Active account closing early, not a matured one. */
get daysRemainingToMaturity(): number | null {
const maturity = this.data.maturityDate ? this.dateUtils.parseDate(this.data.maturityDate) : null;
if (!maturity) {
return null;
}
return Math.max(0, this.dateUtils.calculateDiff(this.referenceDate, maturity));
}
/** Elapsed tenure as Years/Months/Days, the way a real passbook states it, rather than a raw day
* count that is hard to read for a multi-year FD. */
get tenureElapsedDisplay(): string {
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
if (!start) {
return '—';
}
const { years, months, days } = PrematureCloseDialogComponent.yearsMonthsDaysBetween(start, this.referenceDate);
const parts: string[] = [];
if (years > 0) {
parts.push(`${years} yr${years === 1 ? '' : 's'}`);
}
if (months > 0) {
parts.push(`${months} mo${months === 1 ? '' : 's'}`);
}
if (days > 0 || parts.length === 0) {
parts.push(`${days} day${days === 1 ? '' : 's'}`);
}
return parts.join(', ');
}
private formatFullDate(value: any): string {
if (!value) {
return '—';
}
const parsed = this.dateUtils.parseDate(value);
// "28 September 2026" -- day/month-name/year, so this reads unambiguously regardless of the
// account's own configured short date format elsewhere in the app.
return this.dateUtils.formatDate(parsed, 'd MMMM y');
}
private static yearsMonthsDaysBetween(start: Date, end: Date): { years: number; months: number; days: number } {
let years = end.getFullYear() - start.getFullYear();
let months = end.getMonth() - start.getMonth();
let days = end.getDate() - start.getDate();
if (days < 0) {
months -= 1;
const daysInPreviousMonth = new Date(end.getFullYear(), end.getMonth(), 0).getDate();
days += daysInPreviousMonth;
}
if (months < 0) {
years -= 1;
months += 12;
}
return { years: Math.max(0, years), months: Math.max(0, months), days: Math.max(0, days) };
}
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
@@ -201,7 +418,18 @@ export class PrematureCloseDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data)
.subscribe({
next: () => {
next: (response: any) => {
// A command on a maker-checker permission is parked in the Checker Inbox, not executed:
// the platform rolls its transaction back and still answers HTTP 200 with
// rollbackTransaction: true. Without saying so, the dialog closes and the account reloads
// unchanged -- which reads as "nothing happened" rather than "waiting for approval".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Premature closure has been submitted for approval. The deposit is unchanged until a checker approves it.'
});
}
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
@@ -30,6 +30,7 @@ import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
@@ -74,6 +75,7 @@ import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dor
export class FixedDepositAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
@@ -298,6 +300,18 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
/**
* The start date of the term currently running -- the last entry in renewalHistory, which is ordered
* oldest-first (see RenewalHistoryTabComponent). Falls back to the account's own activation date for
* an FD that has never been renewed (or whose renewalHistory hasn't loaded), which is the same date
* in that case anyway.
*/
private get currentTermStartDate(): any {
const history = this.fixedDepositsAccountData?.renewalHistory;
const latestTerm = Array.isArray(history) && history.length > 0 ? history[history.length - 1] : null;
return latestTerm?.activatedondate ?? this.fixedDepositsAccountData?.timeline?.activatedOnDate;
}
/**
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
* matured yet.
@@ -316,7 +330,21 @@ export class FixedDepositAccountViewComponent implements OnInit {
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType,
// Gate which RBI-mandated waiver reasons the dialog offers -- these two are the product's own
// facility toggles (Deceased Settlement / No Penalty Nominee-Heir Closure), off by default, so
// a product that never enabled them shouldn't offer a reason implying it did.
deceasedSettlement: this.fixedDepositsAccountData.deceasedSettlement,
noPenaltyNomineeHeirClosure: this.fixedDepositsAccountData.noPenaltyNomineeHeirClosure,
// Tenure context (current term's start date, maturity date, current balance) so the dialog can
// show how far into THIS term the account is and how much is actually sitting in it today, not
// just the penalty-adjusted amount calculatePrematureAmount returns. Deliberately the current
// term's own start (see currentTermStartDate), not the account's original activation -- a
// renewed FD's interest/tenure runs from the renewal, and showing the original activation date
// here would make an account renewed days ago look like it has been running for months.
activatedOnDate: this.currentTermStartDate,
maturityDate: this.fixedDepositsAccountData.maturityDate,
currentBalance: this.fixedDepositsAccountData.summary?.accountBalance
}
});
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
@@ -393,7 +421,16 @@ export class FixedDepositAccountViewComponent implements OnInit {
if (response.confirm) {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {})
.subscribe(() => {
.subscribe((commandResponse: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with
// rollbackTransaction: true), so the reload below shows no new interest posting.
if (commandResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Interest posting has been sent for checker approval. No interest is posted until a checker approves it.'
});
}
this.reload();
});
}
@@ -41,8 +41,8 @@
) {
<div class="rh-node">
<div class="rh-node__rail">
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
@if (term.renewalstatus === 'ACTIVE') {
<span class="rh-node__dot" [class.rh-node__dot--active]="isTermActive(term, last)">
@if (isTermActive(term, last)) {
<span class="rh-node__pulse"></span>
}
</span>
@@ -51,15 +51,15 @@
}
</div>
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
<div class="rh-card" [class.rh-card--active]="isTermActive(term, last)">
<div class="rh-card__head">
<div class="rh-card__title-group">
<h4 class="rh-card__title">
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
</h4>
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
<span class="rh-badge" [class.rh-badge--active]="isTermActive(term, last)">
<fa-icon [icon]="isTermActive(term, last) ? 'check-circle' : 'clock'"></fa-icon>
{{ termStatusLabel(term, last) }}
</span>
@if (renewalTypeLabel(term)) {
<span class="rh-tag">
@@ -95,7 +95,7 @@
</div>
<div class="rh-stat">
<span class="rh-stat__label">{{
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
isTermActive(term, last) ? 'Interest So Far' : 'Interest Earned'
}}</span>
<span class="rh-stat__value rh-stat__value--positive">
{{
@@ -106,9 +106,7 @@
</span>
</div>
<div class="rh-stat rh-stat--highlight">
<span class="rh-stat__label">{{
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
}}</span>
<span class="rh-stat__label">{{ maturityLabel(term, last) }}</span>
<span class="rh-stat__value">
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
</span>
@@ -183,6 +183,51 @@ export class RenewalHistoryTabComponent implements OnInit {
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
}
/**
* Whether one term's card should show as "still running". `term.renewalstatus` only ever flips from
* ACTIVE to MATURED when a term rolls into its successor (see FixedDepositRenewalHistory#markMatured
* on the backend) -- a premature closure never touches that row, so the latest term's archived status
* would read ACTIVE forever even after the whole account has closed. For the LAST term only, this
* cross-checks the account's own live status instead of trusting the archived flag; every earlier
* term is reliably matured already (something renewed past it), so its own renewalstatus is
* trustworthy as-is and doesn't need the live account status at all.
*/
isTermActive(term: any, isLast: boolean): boolean {
if (isLast && this.fixedDepositsAccountData?.status) {
return !!this.fixedDepositsAccountData.status.active;
}
return term?.renewalstatus === 'ACTIVE';
}
/** Badge text for one term's card -- see {@link isTermActive} for why the last term needs its own check. */
termStatusLabel(term: any, isLast: boolean): string {
if (this.isTermActive(term, isLast)) {
return 'Active';
}
if (isLast && this.fixedDepositsAccountData?.status?.prematureClosed) {
return 'Premature Closed';
}
return 'Matured';
}
/**
* Label for the maturity/value figure -- "Projected Maturity" only makes sense while the term is
* still genuinely running towards that projection. `term.maturedamount` (the archived settled value)
* is only ever written when a term rolls into its successor, same gap as {@link isTermActive}: a
* premature closure settles the account without ever touching that column, so the last term would
* keep reading as a live projection forever even though the figure showing is already the final,
* settled one (see {@link maturityFor}).
*/
maturityLabel(term: any, isLast: boolean): string {
if (term?.maturedamount != null) {
return 'Matured At';
}
if (isLast && !this.isTermActive(term, isLast)) {
return 'Closed At';
}
return 'Projected Maturity';
}
/**
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
*
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -98,17 +100,31 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'approve', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true).
// The account is therefore still pending, so chaining activation onto it would be applied to an
// unapproved account -- stop here and say what is actually waiting.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'This approval has been sent for checker approval. The account stays pending until a checker approves it.'
});
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
return;
}
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -21,6 +21,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -43,6 +44,7 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -139,8 +141,18 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'close', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'close', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the deposit reloads unchanged -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Closure has been sent for checker approval. The deposit is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -97,8 +99,19 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'reject', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'reject', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the application is still open -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Rejection has been sent for checker approval. The application is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -12,6 +12,7 @@ import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angu
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { AlertService } from 'app/core/alert/alert.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
@@ -35,6 +36,7 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
private fixedDepositsService = inject(FixedDepositsService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** Undo Approval Fixed Deposits Account form. */
undoApprovalFixedDepositsAccountForm: UntypedFormGroup;
@@ -87,12 +89,30 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
if (this.undoAction === 'Undo Activation') {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe(() => {
.subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route });
});
} else {
this.savingsService.executeSavingsAccountCommand(this.accountId, this.undoCommand, data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
this.savingsService
.executeSavingsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
/**
* Both undo commands sit on maker-checker permissions, so either can come back parked in the
* Checker Inbox rather than executed -- HTTP 200 with rollbackTransaction: true, and the account
* reloaded unchanged. Without this the navigation away reads as "it worked".
*/
private notifyIfSentForApproval(response: any): void {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: `${this.undoAction} has been sent for checker approval. The account is unchanged until a checker approves it.`
});
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -99,7 +101,16 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'withdrawnByApplicant', data)
.subscribe(() => {
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the application is still open -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Withdrawal has been sent for checker approval. The application is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
@@ -18,9 +18,16 @@
<p class="dialog-sub">NDTL &middot; CRR/SLR Computation Base &middot; As per RBI Master Circular</p>
</div>
<div class="header-right">
<!-- The close being reported, and under it the close the ratios are charged on. Two dates,
because they are not the same day and the requirement belongs to the earlier one. -->
<div class="as-of-box">
<strong>{{ reportingDateStr }}</strong>
<span>Reporting Fortnight (45-day lag)</span>
<strong>{{ reportingDateStr || '—' }}</strong>
<span>
Fortnight close
@if (baseDateStr) {
&middot; charged on {{ baseDateStr }}
}
</span>
</div>
<a
class="rbi-circular-btn"
@@ -42,31 +49,44 @@
<div class="summary-row">
<div class="summary-card sc-demand">
<div class="sc-label">Total Demand Liabilities</div>
<div class="sc-value">&#8377;12,843<span class="cr">Cr</span></div>
<div class="sc-change up">&#8593; 3.2% vs prev fortnight</div>
<div class="sc-value">{{ crore(totalDemand) }}<span class="cr" *ngIf="totalDemand !== null">Cr</span></div>
<div class="sc-sub">Payable on demand</div>
</div>
<div class="summary-card sc-time">
<div class="sc-label">Total Time Liabilities</div>
<div class="sc-value">&#8377;64,618<span class="cr">Cr</span></div>
<div class="sc-change up">&#8593; 1.8% vs prev fortnight</div>
<div class="sc-value">{{ crore(totalTime) }}<span class="cr" *ngIf="totalTime !== null">Cr</span></div>
<div class="sc-sub">Payable on maturity</div>
</div>
<div class="summary-card sc-asset">
<div class="sc-label">Assets with Banking System</div>
<div class="sc-value">&#8377;8,240<span class="cr">Cr</span></div>
<div class="sc-value">
{{ crore(totalBankingAssets) }}<span class="cr" *ngIf="totalBankingAssets !== null">Cr</span>
</div>
<div class="sc-change dn">&#8595; Deducted from NDTL</div>
</div>
<div class="summary-card sc-ndtl">
<div class="sc-label">Net Demand &amp; Time Liabilities (NDTL)</div>
<div class="sc-value">&#8377;69,221<span class="cr">Cr</span></div>
<div class="sc-value">{{ crore(ndtl) }}<span class="cr" *ngIf="ndtl !== null">Cr</span></div>
<div class="sc-sub">After deducting banking assets</div>
</div>
<div class="summary-card sc-crr">
<div class="sc-label">CRR Required ({{ crrRate }}%)</div>
<div class="sc-value">&#8377;{{ crrRequired | number: '1.0-0' }}<span class="cr">Cr</span></div>
<div class="sc-sub">SLR Req: &#8377;{{ slrRequired | number: '1.0-0' }} Cr ({{ slrRate }}%)</div>
<!-- The rate is named from the configuration, not fixed here: a card headed "CRR Required
(3%)" while Admin holds 4% states a rate the bank is not on. -->
<div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div>
<div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
<div class="sc-sub">
SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}
</div>
</div>
</div>
<!-- Why the figures are not there, in the return's own words. A dashboard that simply shows
dashes sends an administrator looking for a fault in the dashboard. -->
<div class="unavailable-note" *ngIf="unavailable">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ unavailable }}
</div>
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="tab-btn" [class.active]="activeTab === 'demand'" (click)="activeTab = 'demand'">
@@ -124,13 +144,21 @@
</td>
</tr>
}
@empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in
Admin &rarr; RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total Demand Liabilities (All 16 Items)</strong></td>
<td class="right foot-demand">12,114.75</td>
<td class="right foot-demand">12,843.50</td>
<td class="right up">+728.75</td>
<td colspan="3"><strong>Total Demand Liabilities ({{ demandLiabilities.length }} accounts)</strong></td>
<td class="right foot-demand">{{ totalOf(demandLiabilities, "openingValue") }}</td>
<td class="right foot-demand">{{ totalOf(demandLiabilities, "currentValue") }}</td>
<td class="right">{{ totalChange(demandLiabilities) }}</td>
<td></td>
</tr>
</tfoot>
@@ -179,13 +207,21 @@
</td>
</tr>
}
@empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in
Admin &rarr; RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total Time Liabilities (All 13 Items)</strong></td>
<td class="right foot-time">62,613.10</td>
<td class="right foot-time">64,618.00</td>
<td class="right up">+2,004.90</td>
<td colspan="3"><strong>Total Time Liabilities ({{ timeLiabilities.length }} accounts)</strong></td>
<td class="right foot-time">{{ totalOf(timeLiabilities, "openingValue") }}</td>
<td class="right foot-time">{{ totalOf(timeLiabilities, "currentValue") }}</td>
<td class="right">{{ totalChange(timeLiabilities) }}</td>
<td></td>
</tr>
</tfoot>
@@ -228,15 +264,23 @@
</td>
</tr>
}
@empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in
Admin &rarr; RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
</td>
<td class="right foot-asset">7,600.70</td>
<td class="right foot-asset">8,240.20</td>
<td class="right up">+639.50</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, "openingValue") }}</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, "currentValue") }}</td>
<td class="right">{{ totalChange(bankingAssets) }}</td>
<td></td>
</tr>
</tfoot>
@@ -285,13 +329,21 @@
</td>
</tr>
}
@empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in
Admin &rarr; RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4"><strong>Total Liquid Assets (SLR Eligible)</strong></td>
<td class="right foot-slr">12,511.70</td>
<td class="right foot-slr">13,238.80</td>
<td class="right up">+727.10</td>
<td class="right foot-slr">{{ totalOf(liquidAssets, "openingValue") }}</td>
<td class="right foot-slr">{{ totalOf(liquidAssets, "currentValue") }}</td>
<td class="right">{{ totalChange(liquidAssets) }}</td>
<td></td>
</tr>
</tfoot>
@@ -842,3 +842,30 @@ $slr-b: #bae6fd;
padding: 8px 16px;
}
}
/* Why the figures are missing, in the return's own words — an administrator reading only dashes
goes looking for a fault in this screen rather than in the configuration that actually caused it. */
.unavailable-note {
margin: 0 0 16px;
padding: 10px 14px;
background: #fffbeb;
color: #92400e;
border-radius: 8px;
font-size: 12.5px;
line-height: 1.6;
fa-icon {
margin-right: 8px;
}
}
/* A head with nothing mapped. Said in the table rather than left as an empty body, and it names the
screen where the mapping is made — an empty table otherwise reads as a fault in this one. */
.empty-row td {
padding: 22px 16px;
text-align: center;
color: #6b7280;
font-size: 12.5px;
line-height: 1.6;
background: #fafafa;
}
@@ -6,13 +6,30 @@
* 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 { CommonModule, DecimalPipe } from '@angular/common';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
/** One account under a head, at the base date and the reporting date. */
interface CrrSlrRow {
sl: string;
name: string;
sub: string;
code: string;
opening: string;
current: string;
/** The figures themselves, kept so totals add from values rather than from formatted text. */
openingValue: number | null;
currentValue: number | null;
change: string;
changeDir: 'up' | 'dn' | 'flat';
status: string;
}
@Component({
selector: 'mifosx-crr-slr-dialog',
@@ -29,477 +46,230 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
FaIconComponent
]
})
export class CrrSlrDialogComponent {
export class CrrSlrDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
private rbi = inject(RbiCircularsService);
activeTab: 'demand' | 'time' | 'assets' | 'slr' = 'demand';
/** Reporting fortnight date — 45 days before current date */
today = new Date();
reportingDate: Date;
reportingDateStr: string;
/** The date the figures were struck at, as the return itself reports it. */
reportingDateStr = '';
/** NDTL & Reserve computations */
readonly totalDemand = 12843.5;
readonly totalTime = 64618.0;
readonly totalBankingAssets = 8240.2;
readonly ndtl = 69221.3; // Demand + Time - Banking Assets
readonly crrRate = 3; // Current RBI CRR rate (%)
readonly slrRate = 18; // Current RBI SLR rate (%)
readonly crrRequired = 69221.3 * 0.03; // ₹2,076.64 Cr
readonly slrRequired = 69221.3 * 0.18; // ₹12,459.83 Cr
readonly totalLiquidAssets = 13238.8;
readonly slrActualPercent = +((13238.8 / 69221.3) * 100).toFixed(2); // 19.12%
readonly slrSurplus = +(13238.8 - 69221.3 * 0.18).toFixed(2); // 778.97
readonly totalReserve = +(69221.3 * 0.03 + 69221.3 * 0.18).toFixed(2); // 14,536.47
/**
* The close the ratios are charged on — the second preceding fortnight, not the one reported.
*
* Named on screen because the gap between the two is the whole of how the rules work: a bank
* cannot hold a reserve against liabilities it does not yet know, so the requirement is fixed on
* a position already struck and reported. A screen showing one date invites a reader to think the
* requirement was worked out on the day it is shown against.
*/
baseDateStr = '';
constructor() {
// CRR/SLR uses data from 45 days ago
const lagDays = 45;
this.reportingDate = new Date(this.today);
this.reportingDate.setDate(this.reportingDate.getDate() - lagDays);
this.reportingDateStr = this.reportingDate.toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
loading = true;
/** Set when the return could not be computed — and why, in the return's own words. */
unavailable = '';
/**
* The NDTL computation, read from the return rather than stated here.
*
* These were fixed numbers until now — a demonstration screen that showed ₹69,221 Cr whatever the
* ledger held. The figures are the bank's own regulatory position, so a plausible-looking number
* is worse than no number: it reads as a reported figure and cannot be told apart from one.
*
* Null until the return answers, and left null where it cannot compute. The template shows a dash
* for a null, which says "not reported" where a zero would say "reported as nil".
*/
totalDemand: number | null = null;
totalTime: number | null = null;
totalBankingAssets: number | null = null;
ndtl: number | null = null;
crrRate: number | null = null;
slrRate: number | null = null;
crrRequired: number | null = null;
slrRequired: number | null = null;
totalLiquidAssets: number | null = null;
slrActualPercent: number | null = null;
slrSurplus: number | null = null;
totalReserve: number | null = null;
/**
* A figure for display, or a dash where the return did not carry one.
*
* A dash and a zero say different things — "not reported" against "reported as nil" — and on a
* regulatory screen the difference decides whether anyone goes looking for the missing mapping.
*/
crore(value: number | null): string {
if (value === null) {
return '—';
}
return `₹${value.toLocaleString('en-IN', { maximumFractionDigits: 0 })}`;
}
ngOnInit(): void {
// The return is reported in rupees; this screen reads in crore, as a dashboard tile does.
const toCrore = (v: number | null | undefined): number | null =>
v === null || v === undefined ? null : +(v / 1e7).toFixed(2);
// The statutory Form 1, not the working report. Form 1 is filed twice a month — at the 15th and
// at the month end — and this screen should show the close the bank last filed, not a position
// struck 45 days back by a lag rule that belongs to the other report. Those two disagree by
// weeks, and the 45-day one lands mid-fortnight on a date the bank never reported.
this.rbi.getForm1Statutory().subscribe({
next: (data: any) => {
this.loading = false;
// The later of the two closes the return reports — the one the bank most recently filed.
const columns: string[] = data?.columns ?? [];
const latest = columns.length - 1;
this.reportingDateStr = columns[latest] ?? '';
this.baseDateStr = data?.baseColumns?.[latest] ?? '';
this.crrRate = data?.crrPercentage ?? null;
this.slrRate = data?.slrPercentage ?? null;
if (!columns.length) {
this.unavailable =
data?.unmetRequirements?.[0] ??
'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.';
return;
}
// The return computes but says something would make it wrong to file — a rate never set, a
// base date the ledger cannot answer. Shown rather than swallowed: the reason is the thing
// an administrator needs in order to fix it.
this.unavailable = data?.unmetRequirements?.[0] ?? '';
// Part A's own lines, read by the Roman numeral each carries. Read from the return rather
// than recomputed, so this screen and the filed form cannot come to disagree.
const lines: any[] = data?.partA?.lines ?? [];
const at = (startsWith: string): number | null => {
const line = lines.find((l: any) => String(l?.label ?? '').startsWith(startsWith));
return line?.values?.[latest] ?? null;
};
this.totalDemand = toCrore(at('(A) Demand liabilities'));
this.totalTime = toCrore(at('(B) Time liabilities'));
this.totalBankingAssets = toCrore(at('TOTAL OF III'));
// IV(b) is the base the ratios are actually charged on — the figure behind the requirement,
// not the bank's liabilities on the day being reported.
this.ndtl = toCrore(at('IV. b)'));
this.crrRequired = toCrore(data?.partB?.required?.[latest]);
this.slrRequired = toCrore(data?.partC?.required?.[latest]);
this.totalLiquidAssets = toCrore(data?.partC?.maintained?.[latest]);
if (this.slrRequired !== null && this.totalLiquidAssets !== null) {
this.slrSurplus = +(this.totalLiquidAssets - this.slrRequired).toFixed(2);
}
if (this.ndtl) {
this.slrActualPercent =
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.ndtl) * 100).toFixed(2);
}
if (this.crrRequired !== null && this.slrRequired !== null) {
this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2);
}
this.fillTabs(lines, latest);
},
error: () => {
this.loading = false;
this.unavailable = 'The CRR/SLR position could not be read. The return service did not respond.';
}
});
}
demandLiabilities = [
{
sl: '01',
name: 'Current Account Deposits',
sub: 'Corporate & individual CA balances',
code: '2001-CA',
opening: '3,842.50',
current: '4,120.75',
change: '+278.25',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Savings Bank Deposits (Withdrawable)',
sub: 'Demand portion — 10% of SB balance',
code: '2002-SB',
opening: '1,240.00',
current: '1,318.40',
change: '+78.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Demand Drafts Payable',
sub: 'Issued DDs outstanding for payment',
code: '2003-DD',
opening: '284.20',
current: '311.60',
change: '+27.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '04',
name: "Banker's Cheques / Pay Orders",
sub: 'Issued pay orders pending encashment',
code: '2004-PO',
opening: '198.75',
current: '176.30',
change: '-22.45',
changeDir: 'dn',
status: 'Active'
},
{
sl: '05',
name: 'Credit Balance in Cash Credit Accounts',
sub: 'CC accounts in credit position',
code: '2005-CC',
opening: '542.10',
current: '618.90',
change: '+76.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '06',
name: 'Overdue Fixed Deposits',
sub: 'FDs past maturity — treated as demand',
code: '2006-OFD',
opening: '89.40',
current: '112.85',
change: '+23.45',
changeDir: 'dn',
status: 'Overdue'
},
{
sl: '07',
name: 'Interest Payable on Demand Deposits',
sub: 'Accrued interest on CA/SB',
code: '2007-INT',
opening: '34.20',
current: '38.55',
change: '+4.35',
changeDir: 'up',
status: 'Active'
},
{
sl: '08',
name: 'Margins held against Letters of Credit',
sub: 'Withdrawable LC margin deposits',
code: '2008-LCM',
opening: '412.60',
current: '388.20',
change: '-24.40',
changeDir: 'dn',
status: 'Active'
},
{
sl: '09',
name: 'Margins held against Bank Guarantees',
sub: 'Withdrawable BG margin deposits',
code: '2009-BGM',
opening: '318.40',
current: '342.10',
change: '+23.70',
changeDir: 'up',
status: 'Active'
},
{
sl: '10',
name: 'Security Deposits (Withdrawable)',
sub: 'EMD / SD refundable on demand',
code: '2010-SD',
opening: '124.80',
current: '118.60',
change: '-6.20',
changeDir: 'dn',
status: 'Active'
},
{
sl: '11',
name: 'Unclaimed Deposits (Demand Portion)',
sub: 'Balances unclaimed >10 years',
code: '2011-UCD',
opening: '56.30',
current: '59.40',
change: '+3.10',
changeDir: 'dn',
status: 'Unclaimed'
},
{
sl: '12',
name: 'Inter-Bank Demand Deposits',
sub: 'Demand deposits from other banks',
code: '2012-IBD',
opening: '2,840.00',
current: '3,120.50',
change: '+280.50',
changeDir: 'up',
status: 'Active'
},
{
sl: '13',
name: 'Clearing Adjustments / Balances',
sub: 'Net clearing settlement balances',
code: '2013-CLR',
opening: '186.40',
current: '210.80',
change: '+24.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '14',
name: 'Outstanding Payment Instruments',
sub: 'Cheques issued but not yet presented',
code: '2014-OPI',
opening: '142.60',
current: '158.90',
change: '+16.30',
changeDir: 'up',
status: 'Active'
},
{
sl: '15',
name: 'Demand Liabilities towards Other Banks',
sub: 'Nostro / Vostro demand obligations',
code: '2015-DLB',
opening: '984.20',
current: '1,048.60',
change: '+64.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '16',
name: 'Deposits Repayable on Demand',
sub: 'Miscellaneous on-demand deposits',
code: '2016-DRD',
opening: '618.30',
current: '698.05',
change: '+79.75',
changeDir: 'up',
status: 'Active'
}
];
timeLiabilities = [
{
sl: '01',
name: 'Fixed Deposits (FD)',
sub: '7 days to 10 years · 6.5–7.2%',
code: '3001-FD',
opening: '28,420.50',
current: '29,184.30',
change: '+763.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Recurring Deposits (RD)',
sub: 'Monthly instalment · 6.0–6.8%',
code: '3002-RD',
opening: '4,218.60',
current: '4,412.80',
change: '+194.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Time Liability Portion of Savings',
sub: '90% of SB balance treated as time',
code: '3003-SBT',
opening: '10,840.20',
current: '11,204.60',
change: '+364.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '04',
name: 'Reinvestment Deposits',
sub: 'Cumulative interest plans · 7.0%',
code: '3004-RID',
opening: '1,842.40',
current: '1,918.70',
change: '+76.30',
changeDir: 'up',
status: 'Active'
},
{
sl: '05',
name: 'Staff Security Deposits (Fixed Period)',
sub: 'Employee security deposits — locked',
code: '3005-SSD',
opening: '84.60',
current: '86.40',
change: '+1.80',
changeDir: 'up',
status: 'Locked'
},
{
sl: '06',
name: 'Margin Money for Guarantees / LC',
sub: 'LC/BG margins — fixed lock-in period',
code: '3006-MGM',
opening: '620.80',
current: '644.20',
change: '+23.40',
changeDir: 'up',
status: 'Locked'
},
{
sl: '07',
name: 'Certificates of Deposit (CD)',
sub: 'Issued to corporates / FIs · 7.35%',
code: '3007-CD',
opening: '3,840.00',
current: '3,920.50',
change: '+80.50',
changeDir: 'up',
status: 'Active'
},
{
sl: '08',
name: 'Interest Accrued on Time Deposits',
sub: 'Accrued but unpaid interest on FD/RD',
code: '3008-IAT',
opening: '842.60',
current: '918.40',
change: '+75.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '09',
name: 'Long-term Deposits',
sub: 'Tenor >5 years · 7.5%',
code: '3009-LTD',
opening: '5,620.40',
current: '5,818.60',
change: '+198.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '10',
name: 'Tax Saving Deposits',
sub: 'Sec 80C FDs — 5-year lock-in · 6.8%',
code: '3010-TSD',
opening: '1,240.80',
current: '1,318.40',
change: '+77.60',
changeDir: 'up',
status: 'Locked'
},
{
sl: '11',
name: 'Foreign Currency Deposits',
sub: 'FCNR(B), RFC — USD/EUR/GBP',
code: '3011-FCD',
opening: '2,184.60',
current: '2,348.20',
change: '+163.60',
changeDir: 'up',
status: 'Active'
},
{
sl: '12',
name: 'Time Liabilities to Other Banks',
sub: 'Inter-bank term deposits — call money',
code: '3012-TLB',
opening: '1,840.20',
current: '1,924.40',
change: '+84.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '13',
name: 'Term Borrowings',
sub: 'Bonds, debentures, refinance · 8.1%',
code: '3013-TBR',
opening: '1,018.40',
current: '1,118.50',
change: '+100.10',
changeDir: 'up',
status: 'Active'
/**
* The four tabs, filled from the return's own lines.
*
* These were four fixed lists until now — 435 lines of invented accounts with invented balances,
* which a reader had no way to tell apart from the bank's real position. They are now built from
* the accounts the return actually read, so a tab is empty exactly when the bank has nothing
* mapped under that head, and says so.
*/
demandLiabilities: CrrSlrRow[] = [];
timeLiabilities: CrrSlrRow[] = [];
bankingAssets: CrrSlrRow[] = [];
liquidAssets: CrrSlrRow[] = [];
/**
* Turns Part A's lines into the rows a tab shows.
*
* The form reports each head at both of the closes it carries, so the earlier column is the
* opening and the later one the current — the same pair these tables have always shown, now taken
* from the form rather than invented. Headings and totals are skipped: a heading carries no
* figure, and a total would be added a second time by the footer below.
*/
private rowsFrom(lines: any[], latest: number, match: (label: string) => boolean): CrrSlrRow[] {
const rows: CrrSlrRow[] = [];
for (const line of lines ?? []) {
const label = String(line?.label ?? '');
if (line?.heading || line?.total || !line?.values || !match(label)) {
continue;
}
const opening = latest > 0 ? (line.values[latest - 1] ?? null) : null;
const current = line.values[latest] ?? null;
const change = opening === null || current === null ? null : current - opening;
rows.push({
sl: String(rows.length + 1).padStart(2, '0'),
// The form's own wording, with its numbering trimmed off the front — the table has a column
// of its own for that.
name: label.replace(/^\s*\(?[a-zA-Z0-9]+\)?[.)]?\s*/, '').trim() || label.trim(),
sub: line.configKey ? String(line.configKey).replace(/_/g, ' ') : 'Derived from the form',
code: line.configKey ?? '—',
opening: this.crore(opening),
current: this.crore(current),
openingValue: opening,
currentValue: current,
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`,
changeDir: change === null ? 'flat' : change >= 0 ? 'up' : 'dn',
// A head with no GL mapped reports nothing, and the form says so rather than reporting nil.
status: line.configured === false ? 'Not mapped' : 'Active'
});
}
];
return rows;
}
bankingAssets = [
{
sl: '01',
name: 'Balances with Other Banks',
sub: 'Nostro / Vostro balances — SBI, PNB, BOB',
code: '4001-BWB',
opening: '3,840.20',
current: '4,112.40',
change: '+272.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Money at Call with Banks',
sub: 'Overnight & short-notice call money',
code: '4002-MCB',
opening: '2,180.50',
current: '2,418.60',
change: '+238.10',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Loans to Banks (Inter-Bank Lending)',
sub: 'Term loans & credit lines to SCBs',
code: '4003-LTB',
opening: '1,580.00',
current: '1,709.20',
change: '+129.20',
changeDir: 'up',
status: 'Active'
}
];
/**
* A tab's column total, added from the rows on screen.
*
* Added from the rows rather than stated separately, so the foot of a table always agrees with
* what is above it. The footers used to be fixed text and agreed with nothing.
*
* Summed from each row's own figure, not from the text it displays: a total parsed back out of
* formatted strings would quietly lose the paise the display rounds away.
*/
totalOf(rows: CrrSlrRow[], column: 'openingValue' | 'currentValue'): string {
const values = rows.map((r) => r[column]).filter((v): v is number => v !== null);
return values.length ? this.crore(values.reduce((a, b) => a + b, 0)) : '—';
}
liquidAssets = [
{
sl: '01',
name: 'Central Government Securities',
sub: 'G-Secs held in HTM, AFS & HFT portfolios',
code: '5001-CGS',
opening: '6,840.50',
current: '7,218.40',
change: '+377.90',
changeDir: 'up',
status: 'Active',
category: 'G-Sec'
},
{
sl: '02',
name: 'State Development Loans (SDLs)',
sub: 'State govt bonds — various maturities',
code: '5002-SDL',
opening: '2,420.30',
current: '2,584.60',
change: '+164.30',
changeDir: 'up',
status: 'Active',
category: 'SDL'
},
{
sl: '03',
name: 'Treasury Bills (91/182/364 day)',
sub: 'Short-term govt securities — discounted',
code: '5003-TB',
opening: '1,840.20',
current: '1,962.80',
change: '+122.60',
changeDir: 'up',
status: 'Active',
category: 'T-Bill'
},
{
sl: '04',
name: 'Gold (valued at current market)',
sub: 'Gold reserves held per RBI valuation norms',
code: '5004-GLD',
opening: '680.40',
current: '724.60',
change: '+44.20',
changeDir: 'up',
status: 'Active',
category: 'Gold'
},
{
sl: '05',
name: 'Special Deposit Scheme with RBI',
sub: 'Deposits placed under SDS — SLR eligible',
code: '5005-SDS',
opening: '420.00',
current: '420.00',
change: '0.00',
changeDir: 'up',
status: 'Active',
category: 'RBI Dep'
},
{
sl: '06',
name: 'NABARD / SIDBI / NHB Bonds',
sub: 'PSE bonds approved as SLR-eligible',
code: '5006-PSE',
opening: '310.80',
current: '328.40',
change: '+17.60',
changeDir: 'up',
status: 'Active',
category: 'PSE Bond'
/** The movement across a tab, between the two closes the form reports. */
totalChange(rows: CrrSlrRow[]): string {
const opening = rows.map((r) => r.openingValue).filter((v): v is number => v !== null);
const current = rows.map((r) => r.currentValue).filter((v): v is number => v !== null);
if (!opening.length || !current.length) {
return '—';
}
];
const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0);
return `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`;
}
/** Fills the four tabs from one reading of the form. */
private fillTabs(lines: any[], latest: number): void {
const has = (label: string, ...words: string[]): boolean => {
const l = label.toLowerCase();
return words.some((w) => l.includes(w));
};
// Matched on how the form labels each head, not on a fixed list of account names: a bank that
// maps a new account to a head gets it on the right tab without this screen being changed.
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit'));
this.timeLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'time liabilit') && !has(l, 'demand and time'));
// III — what the banking system owes this bank, which nets off the liability base.
this.bankingAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'current account balances with sbi', 'balances in all accounts', 'money at call', 'advances and any other')
);
// V, VI and VII — the cash, balances and securities the two ratios are met in.
this.liquidAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'cash on hand', 'reserve bank of india', 'co-operative bank', 'government securities')
);
}
close(): void {
this.dialogRef.close();
@@ -28,17 +28,30 @@
<fa-icon icon="external-link-alt"></fa-icon> View
</button>
</div>
<!-- Read from the return, not stated here. Both the rates and the amounts were fixed text
until now, so the card reported ₹2,077 Cr at 3% whatever the ledger held and whatever
rate Admin was on. -->
<div class="card-metrics">
<div class="metric-row">
<span class="metric-label">CRR Required (3%)</span>
<span class="metric-value">&#8377;2,077 Cr</span>
<span class="metric-label">CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }}</span>
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
</div>
<div class="metric-row">
<span class="metric-label">SLR Required (18%)</span>
<span class="metric-value">&#8377;12,460 Cr</span>
<span class="metric-label">SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span>
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
</div>
</div>
<div class="mini-sub"><span class="tag-success">Within Limits</span></div>
<!-- "Within Limits" is a compliance statement, so it is made only where the return actually
says so. Where it cannot compute, the card says that instead of passing the bank. -->
<div class="mini-sub">
@if (crrSlr.status === 'ok') {
<span class="tag-success">Within Limits</span>
} @else if (crrSlr.status === 'short') {
<span class="tag-danger">Shortfall</span>
} @else {
<span class="tag-muted">Not computed</span>
}
</div>
</div>
<!-- CRAR -->
@@ -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;
@@ -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
});
}
/**
@@ -0,0 +1,322 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ipr">
<div class="ipr-head">
<div>
<h2 class="ipr-head-t"><fa-icon icon="percentage"></fa-icon> Interest Posting Reports</h2>
<div class="ipr-head-s">
Each branch gets its own report for each savings product, generated automatically at 12:00 PM on the posting
date and open to generating or regenerating at any time. The branch approves it, and its approved interest is
posted when the branch closes the day — it cannot close until then. The head office closes last.
</div>
</div>
<div class="ipr-head-a">
<label class="ipr-date">
Posting date
<input type="date" [(ngModel)]="postingDate" (change)="load()" />
</label>
<button class="ipr-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon> Reload</button>
<button class="ipr-btn ipr-btn--primary" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="eye"></fa-icon> Preview interest (today)
</button>
</div>
</div>
@if (isHeadOffice) {
<div class="ipr-tabs">
<button class="ipr-tab" [class.is-active]="tab === 'own'" (click)="switchTab('own')">
<fa-icon icon="building"></fa-icon> My branch{{ myBranch ? ' — ' + myBranch : '' }}
</button>
<button class="ipr-tab" [class.is-active]="tab === 'others'" (click)="switchTab('others')">
<fa-icon icon="sitemap"></fa-icon> Other branches
</button>
</div>
@if (tab === 'others') {
<div class="ipr-alert ipr-alert--info">
<fa-icon icon="info-circle"></fa-icon>
<div>
Each branch generates and approves its own reports. The head office can act here only for a branch that has
not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO).
</div>
</div>
}
}
@if (showPreview) {
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>Interest preview — earned up to {{ previewAsOf }}</h3>
<div class="muted">
What each account would be paid if interest were posted today. View only — nothing is saved, approved or
posted.
</div>
</div>
<div class="ipr-total">
<span>Total so far</span>
<strong>₹{{ previewTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ previewRows.length }} account(s)</span>
</div>
</div>
<div class="ipr-preview-actions">
<button class="ipr-btn ipr-btn--slim" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="undo"></fa-icon> Refresh
</button>
<button
class="ipr-btn ipr-btn--slim"
(click)="exportPreview()"
[disabled]="previewLoading || previewRows.length === 0"
>
<fa-icon icon="file-excel"></fa-icon> Export Excel
</button>
<button class="ipr-btn ipr-btn--slim" (click)="closePreview()"><fa-icon icon="times"></fa-icon> Close</button>
</div>
@if (previewLoading) {
<div class="ipr-empty">Calculating interest up to today…</div>
} @else {
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Posting date</th>
<th class="num">Accounts</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (g of previewGroups; track g.officeName + g.productName) {
<tr>
<td>{{ g.officeName }}</td>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>{{ g.postingDate }}</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">₹{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="ipr-empty">No savings account has interest accrued in its current cycle.</td>
</tr>
}
</tbody>
</table>
@if (previewRows.length > 0) {
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Account no</th>
<th>Account holder</th>
<th class="num">Balance</th>
<th class="num">Rate %</th>
<th>Cycle</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (r of previewRows; track r.savingsAccountId) {
<tr>
<td>{{ r.officeName }}</td>
<td>{{ r.productName }}</td>
<td>{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td class="num">₹{{ r.balance | number: '1.2-2' }}</td>
<td class="num">{{ r.rate | number: '1.2-2' }}</td>
<td class="muted">{{ r.cycleStart }} → {{ r.postingDate }}</td>
<td class="num">₹{{ r.interest | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ previewTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
}
}
</div>
}
@if (errorMessage) {
<div class="ipr-alert ipr-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (notice) {
<div class="ipr-alert ipr-alert--ok">
<fa-icon icon="check"></fa-icon>
<div>{{ notice }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="ipr-alert ipr-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>{{ pendingCount }} report(s) waiting for approval. The day cannot be closed until they are approved.</div>
</div>
}
@if (due.length > 0) {
<div class="ipr-alert ipr-alert--warn ipr-due">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div class="ipr-due-body">
<div>Interest is due but no report has been generated yet for:</div>
@for (d of due; track d.productId + '-' + d.officeId) {
<div class="ipr-due-row">
<strong>{{ d.productName }}</strong> <span>· {{ d.officeName }} · {{ d.postingDate }}</span>
@if (d.canAct) {
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="generate(d)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> {{ tab === 'others' ? 'Generate on behalf' : 'Generate report' }}
</button>
} @else if (d.blockedReason) {
<span class="ipr-blocked">{{ d.blockedReason }}</span>
}
</div>
}
</div>
</div>
}
<div class="ipr-card">
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Posting date</th>
<th class="num">Accounts</th>
<th class="num">Total interest</th>
<th>Status</th>
<th>Generated</th>
<th>Approved</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of reports; track r.id) {
<tr [class.is-selected]="selected?.id === r.id" (click)="open(r)">
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td>{{ r.postingDate }}</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">₹{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="ipr-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
@if (r.failureReason) {
<div class="ipr-fail" [title]="r.failureReason">Posting failed: {{ r.failureReason }}</div>
}
</td>
<td class="muted">
{{ r.generatedBy || 'System' }} · {{ r.generationMode }}<br />{{ r.generatedAt | date: 'dd MMM, HH:mm' }}
</td>
<td class="muted">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="ipr-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
—
}
</td>
<td class="actions" (click)="$event.stopPropagation()">
@if (canRegenerate(r)) {
<button class="ipr-btn ipr-btn--slim" (click)="regenerate(r)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> Regenerate
</button>
}
@if (canApprove(r)) {
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="approve(r)" [disabled]="busy">
<fa-icon icon="check"></fa-icon> {{ tab === 'others' ? 'Approve on behalf' : 'Approve' }}
</button>
}
@if (!r.canAct && r.blockedReason && (r.status === 'GENERATED' || r.status === 'APPROVED')) {
<span class="ipr-blocked">{{ r.blockedReason }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="ipr-empty">
@if (loading) {
Loading…
} @else {
No interest posting report for {{ postingDate || 'this date' }}. Reports are generated at 12:00 PM on a
product's posting date.
}
</td>
</tr>
}
</tbody>
</table>
</div>
@if (selected) {
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>{{ selected.officeName }} · {{ selected.productName }} — {{ selected.postingDate }}</h3>
<div class="muted">
Dr {{ selected.expenseGlCode }} {{ selected.expenseGlName }} → Cr {{ selected.payableGlCode }}
{{ selected.payableGlName }}; Dr {{ selected.payableGlCode }} → Cr {{ selected.paidGlCode }}
{{ selected.paidGlName }}; then Dr {{ selected.paidGlCode }} → Cr each account below
</div>
</div>
<div class="ipr-total">
<span>Total interest</span>
<strong>₹{{ selected.totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ selected.accountCount }} account(s)</span>
</div>
</div>
<table class="ipr-table">
<thead>
<tr>
<th>Account no</th>
<th>Account holder</th>
<th class="num">Interest</th>
<th class="num">Balance before posting</th>
<th class="num">Balance after posting</th>
<th>Posted txn</th>
</tr>
</thead>
<tbody>
@for (l of selected.lines; track l.savingsAccountId) {
<tr>
<td>{{ l.accountNo }}</td>
<td>{{ l.clientName }}</td>
<td class="num">₹{{ l.interestAmount | number: '1.2-2' }}</td>
<td class="num">₹{{ l.balanceBefore | number: '1.2-2' }}</td>
<td class="num">₹{{ l.balanceAfter | number: '1.2-2' }}</td>
<td class="muted">{{ l.savingsTransactionId ? '#' + l.savingsTransactionId : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ selected.totalInterest | number: '1.2-2' }}</strong>
</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,327 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
--i-navy: #1a237e;
--i-blue: #1d4ed8;
--i-blue-l: #eff6ff;
--i-green: #15803d;
--i-green-l: #dcfce7;
--i-amber: #b45309;
--i-amber-l: #fef3c7;
--i-red: #c0392b;
--i-red-l: #fdecea;
--i-g100: #f1f5f9;
--i-g200: #e2e8f0;
--i-g500: #64748b;
--i-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--i-g900);
}
.ipr {
max-width: 1280px;
margin: 0 auto;
padding: 16px;
}
.ipr-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
margin-bottom: 14px;
}
.ipr-head-t {
margin: 0;
font-size: 22px;
color: var(--i-navy);
}
.ipr-head-s {
max-width: 720px;
margin-top: 4px;
font-size: 13px;
color: var(--i-g500);
}
.ipr-head-a {
display: flex;
align-items: flex-end;
gap: 8px;
}
.ipr-date {
display: flex;
flex-direction: column;
font-size: 11px;
color: var(--i-g500);
input,
select {
padding: 6px 8px;
border: 1px solid var(--i-g200);
border-radius: 6px;
font: inherit;
font-size: 13px;
}
}
.ipr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--i-g200);
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 13px;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: default;
}
}
.ipr-btn--primary {
border-color: var(--i-blue);
background: var(--i-blue);
color: #fff;
}
.ipr-btn--slim {
padding: 4px 10px;
font-size: 12px;
}
.ipr-alert {
display: flex;
gap: 8px;
margin-bottom: 10px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.ipr-alert--error {
background: var(--i-red-l);
color: var(--i-red);
}
.ipr-alert--ok {
background: var(--i-green-l);
color: var(--i-green);
}
.ipr-alert--warn {
background: var(--i-amber-l);
color: var(--i-amber);
}
.ipr-card {
margin-bottom: 14px;
border: 1px solid var(--i-g200);
border-radius: 10px;
background: #fff;
overflow-x: auto;
}
.ipr-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 9px 12px;
border-bottom: 1px solid var(--i-g100);
text-align: left;
vertical-align: top;
}
th {
background: var(--i-g100);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--i-g500);
}
tbody tr {
cursor: pointer;
}
tbody tr.is-selected {
background: var(--i-blue-l);
}
.num {
text-align: right;
white-space: nowrap;
}
.actions {
display: flex;
gap: 6px;
justify-content: flex-end;
cursor: default;
}
tfoot td {
background: var(--i-g100);
}
}
.muted {
color: var(--i-g500);
font-size: 12px;
}
.ipr-empty {
padding: 24px !important;
text-align: center !important;
color: var(--i-g500);
}
.ipr-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.ipr-badge--pending {
background: var(--i-amber-l);
color: var(--i-amber);
}
.ipr-badge--approved {
background: var(--i-blue-l);
color: var(--i-blue);
}
.ipr-badge--posted {
background: var(--i-green-l);
color: var(--i-green);
}
.ipr-badge--muted {
background: var(--i-g100);
color: var(--i-g500);
}
.ipr-fail {
margin-top: 4px;
font-size: 11px;
color: var(--i-red);
}
.ipr-detail-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
h3 {
margin: 0 0 4px;
font-size: 16px;
color: var(--i-navy);
}
}
.ipr-total {
display: flex;
flex-direction: column;
align-items: flex-end;
strong {
font-size: 22px;
color: var(--i-navy);
}
}
.ipr-due-body {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.ipr-due-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
// Approved by the head office on behalf of a branch that did not open that day.
.ipr-behalf {
margin-left: 4px;
font-size: 11px;
font-weight: 600;
color: var(--i-amber);
}
// Preview: refresh / export / close, above the totals.
.ipr-preview-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
padding: 0 16px 10px;
}
.ipr-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
border-bottom: 1px solid var(--i-g200);
}
.ipr-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
margin-bottom: -1px;
font-size: 13px;
font-weight: 600;
color: var(--i-g500);
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 6px 6px 0 0;
&.is-active {
color: var(--i-navy);
background: #fff;
border-color: var(--i-g200);
border-bottom-color: #fff;
}
}
.ipr-alert--info {
background: var(--i-blue-l);
color: var(--i-blue);
}
.ipr-blocked {
display: inline-block;
max-width: 320px;
font-size: 11px;
line-height: 1.35;
color: var(--i-g500);
white-space: normal;
}
@@ -0,0 +1,398 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
InterestPostingDue,
InterestPostingReport,
InterestPostingReportDetail,
InterestPostingReportService,
InterestPreviewRow,
ReportScope
} from './interest-posting-report.service';
/**
* Savings interest posting reports.
*
* On a product's posting date the system generates, at noon, the list of what each savings account
* will be paid. It is reviewed and approved here; the approved amounts are posted when the last
* branch closes the day, and until then no branch can close. Regenerating replaces the report and
* needs approving again.
*/
@Component({
selector: 'mifosx-interest-posting-report',
templateUrl: './interest-posting-report.component.html',
styleUrls: ['./interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class InterestPostingReportComponent implements OnInit {
private reportService = inject(InterestPostingReportService);
private settingsService = inject(SettingsService);
postingDate = '';
loading = false;
busy = false;
errorMessage = '';
notice = '';
reports: InterestPostingReport[] = [];
selected: InterestPostingReportDetail | null = null;
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
due: InterestPostingDue[] = [];
/** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */
tab: ReportScope = 'own';
isHeadOffice = false;
myBranch = '';
ngOnInit(): void {
this.postingDate = this.businessDateIso();
this.reportService.context().subscribe({
next: (ctx) => {
this.isHeadOffice = !!ctx?.headOffice;
this.myBranch = ctx?.officeName ?? '';
},
error: () => (this.isHeadOffice = false)
});
this.load();
}
switchTab(tab: ReportScope): void {
if (this.tab === tab) {
return;
}
this.tab = tab;
this.selected = null;
this.notice = '';
this.load();
}
/** Acting for another branch is the head office's exception, so it is spelt out before it happens. */
private confirmOnBehalf(officeName: string, action: string): boolean {
if (this.tab !== 'others') {
return true;
}
return confirm(
`${officeName} has not opened today. You are about to ${action} its interest posting report on its behalf, ` +
'from the head office. Continue?'
);
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({
next: (reports) => {
this.reports = reports ?? [];
this.loading = false;
const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id);
if (stillThere) {
this.open(stillThere);
} else {
this.selected = null;
}
},
error: (err) => {
this.loading = false;
this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.');
}
});
this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({
next: (due) => (this.due = due ?? []),
error: () => (this.due = [])
});
}
generate(item: InterestPostingDue): void {
if (!this.confirmOnBehalf(item.officeName, 'generate')) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report generated for ${item.productName} — ${item.officeName}. Review and approve it.`;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not generate the report.');
}
});
}
open(report: InterestPostingReport): void {
this.errorMessage = '';
this.reportService.detail(report.id).subscribe({
next: (detail) => (this.selected = detail),
error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.'))
});
}
regenerate(report: InterestPostingReport): void {
if (
!confirm(
`Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.`
) ||
!this.confirmOnBehalf(report.officeName, 'regenerate')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report regenerated for ${report.productName}. Review and approve it.`;
this.selected = null;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not regenerate the report.');
}
});
}
approve(report: InterestPostingReport): void {
if (
!confirm(
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} account(s) for ${report.productName}? ` +
'It will be posted when the branch closes the day.'
) ||
!this.confirmOnBehalf(report.officeName, 'approve')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.approve(report.id).subscribe({
next: () => {
this.busy = false;
this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`;
this.load();
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not approve the report.');
}
});
}
canRegenerate(report: InterestPostingReport): boolean {
return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED');
}
canApprove(report: InterestPostingReport): boolean {
return report.canAct && report.status === 'GENERATED';
}
statusClass(status: string): string {
return (
{
GENERATED: 'ipr-badge--pending',
APPROVED: 'ipr-badge--approved',
POSTED: 'ipr-badge--posted',
SUPERSEDED: 'ipr-badge--muted'
}[status] ?? 'ipr-badge--muted'
);
}
/** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
get pendingCount(): number {
return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0;
}
// ── Preview: what each account has earned so far, as if posted today. View only. ──
showPreview = false;
previewLoading = false;
previewRows: InterestPreviewRow[] = [];
previewAsOf = '';
openPreview(): void {
this.showPreview = true;
this.previewLoading = true;
this.errorMessage = '';
this.reportService.preview().subscribe({
next: (rows) => {
this.previewRows = rows ?? [];
this.previewAsOf = this.previewRows[0]?.asOf ?? this.businessDateIso();
this.previewLoading = false;
},
error: (err) => {
this.previewLoading = false;
this.previewRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the interest preview.');
}
});
}
closePreview(): void {
this.showPreview = false;
}
get previewTotal(): number {
return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0);
}
/** Totals by branch and product, for the summary above the account list and the export. */
get previewGroups(): {
officeName: string;
productName: string;
postingDate: string;
accounts: number;
interest: number;
}[] {
const map = new Map<
string,
{ officeName: string; productName: string; postingDate: string; accounts: number; interest: number }
>();
this.previewRows.forEach((r) => {
const key = `${r.officeName}|${r.productName}`;
const g = map.get(key) ?? {
officeName: r.officeName,
productName: r.productName,
postingDate: r.postingDate,
accounts: 0,
interest: 0
};
g.accounts += 1;
g.interest += Number(r.interest || 0);
map.set(key, g);
});
return [...map.values()];
}
exportPreview(): void {
const stamp = `Interest earned to ${this.previewAsOf} — preview, not posted`;
exportWorkbook(
[
{
sheetName: 'By account',
title: 'Savings Interest Preview',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Account No',
'Customer',
'Balance',
'Rate %',
'Cycle Start',
'Posting Date',
'Interest So Far'
],
widths: [
18,
22,
20,
26,
16,
8,
12,
12,
16
],
rows: [
...this.previewRows.map((r) => [
r.officeName,
r.productName,
r.accountNo,
r.clientName,
Number(r.balance || 0),
Number(r.rate || 0),
r.cycleStart,
r.postingDate,
Number(r.interest || 0)
]),
[
'TOTAL',
'',
'',
'',
'',
'',
'',
'',
this.previewTotal
]
]
},
{
sheetName: 'By branch & product',
title: 'Savings Interest Preview — Totals',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Posting Date',
'Accounts',
'Interest So Far'
],
widths: [
18,
22,
12,
10,
16
],
rows: [
...this.previewGroups.map((g) => [
g.officeName,
g.productName,
g.postingDate,
g.accounts,
g.interest
]),
[
'TOTAL',
'',
'',
this.previewRows.length,
this.previewTotal
]
]
}
],
`savings-interest-preview-${this.previewAsOf}`
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
private messageOf(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback
);
}
}
@@ -0,0 +1,180 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export type InterestPostingReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED';
/** Which branches a list covers: the user's own, or (head office only) every other branch. */
export type ReportScope = 'own' | 'others';
export interface InterestPostingReport {
id: number;
productId: number;
productName: string;
officeId: number;
officeName: string;
postingDate: string;
status: InterestPostingReportStatus;
totalInterest: number;
accountCount: number;
generationMode: 'AUTO' | 'MANUAL';
generatedAt: string;
generatedBy: string | null;
approvedAt: string | null;
approvedBy: string | null;
approvedOnBehalf: boolean;
postedAt: string | null;
failureReason: string | null;
/** Whether the signed-in user may regenerate or approve it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
/** Why not, where canAct is false. */
blockedReason: string | null;
}
export interface InterestPostingReportLine {
savingsAccountId: number;
accountNo: string;
clientName: string;
interestAmount: number;
balanceBefore: number;
balanceAfter: number;
savingsTransactionId: number | null;
}
export interface InterestPostingReportDetail extends InterestPostingReport {
payableGlCode: string | null;
payableGlName: string | null;
paidGlCode: string | null;
paidGlName: string | null;
expenseGlCode: string | null;
expenseGlName: string | null;
lines: InterestPostingReportLine[];
}
export interface InterestPostingDue {
productId: number;
productName: string;
officeId: number;
officeName: string;
postingDate: string;
canAct: boolean;
blockedReason: string | null;
}
export interface InterestPostingSummaryRow {
id: number;
postingDate: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
accountCount: number;
totalInterest: number;
status: InterestPostingReportStatus;
approvedBy: string | null;
approvedOnBehalf: boolean;
approvedAt: string | null;
postedAt: string | null;
}
export interface InterestPreviewRow {
officeId: number;
officeName: string;
productId: number;
productName: string;
savingsAccountId: number;
accountNo: string;
clientName: string;
balance: number;
rate: number;
cycleStart: string;
postingDate: string;
asOf: string;
interest: number;
}
/**
* The savings interest posting reports, one per branch and product: generated at noon on a posting
* date, approved by the branch, posted when the branch closes the day.
*/
@Injectable({ providedIn: 'root' })
export class InterestPostingReportService {
private http = inject(HttpClient);
private readonly base = '/savingsinterestpostingreports';
/** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */
list(
postingDate?: string,
status?: string,
officeId?: number,
scope?: ReportScope
): Observable<InterestPostingReport[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<InterestPostingReport[]>(this.base, { params });
}
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
dueWithoutReport(postingDate?: string, scope?: ReportScope): Observable<InterestPostingDue[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
return this.http.get<InterestPostingDue[]>(`${this.base}/due-without-report`, { params });
}
/** The signed-in user's branch, and whether it is the head office. */
context(): Observable<{ officeId: number; officeName: string; headOffice: boolean }> {
return this.http.get<{ officeId: number; officeName: string; headOffice: boolean }>(`${this.base}/context`);
}
detail(reportId: number): Observable<InterestPostingReportDetail> {
return this.http.get<InterestPostingReportDetail>(`${this.base}/${reportId}`);
}
regenerate(
productId: number,
officeId: number,
postingDate: string
): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
this.base,
{ productId, officeId, postingDate },
{ params: new HttpParams().set('command', 'regenerate') }
);
}
approve(reportId: number): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
`${this.base}/${reportId}`,
{},
{ params: new HttpParams().set('command', 'approve') }
);
}
/** Interest each account has earned so far this cycle, as if posted today. View only. */
preview(officeId?: number): Observable<InterestPreviewRow[]> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<InterestPreviewRow[]>(`${this.base}/preview`, { params });
}
/** The head office's branch-wise view over a date range. */
summary(fromDate?: string, toDate?: string, productId?: number): Observable<InterestPostingSummaryRow[]> {
let params = new HttpParams();
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (productId) params = params.set('productId', String(productId));
return this.http.get<InterestPostingSummaryRow[]>(`${this.base}/summary`, { params });
}
}
@@ -0,0 +1,172 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ipr">
<div class="ipr-head">
<div>
<h2 class="ipr-head-t"><fa-icon icon="percentage"></fa-icon> Interest Posting Summary</h2>
<div class="ipr-head-s">
Savings interest across every branch: for each posting date, branch and product, how many customers were paid
and how much. Each branch approves and posts its own; this is the head office's view.
</div>
</div>
<div class="ipr-head-a">
<label class="ipr-date">
From
<input type="date" [(ngModel)]="fromDate" (change)="load()" />
</label>
<label class="ipr-date">
To
<input type="date" [(ngModel)]="toDate" (change)="load()" />
</label>
<label class="ipr-date">
Product
<select [(ngModel)]="productId">
<option [ngValue]="null">All products</option>
@for (p of products; track p.id) {
<option [ngValue]="p.id">{{ p.name }}</option>
}
</select>
</label>
<button class="ipr-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon> Reload</button>
<button class="ipr-btn ipr-btn--primary" (click)="exportExcel()" [disabled]="loading || shown.length === 0">
<fa-icon icon="file-excel"></fa-icon> Export Excel
</button>
</div>
</div>
@if (errorMessage) {
<div class="ipr-alert ipr-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="ipr-alert ipr-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>{{ pendingCount }} branch report(s) in this period are not posted yet.</div>
</div>
}
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>Branch totals</h3>
<div class="muted">{{ fromDate }} to {{ toDate }}</div>
</div>
<div class="ipr-total">
<span>Total interest</span>
<strong>₹{{ totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ totalCustomers }} customer(s) · posted ₹{{ postedInterest | number: '1.2-2' }}</span>
</div>
</div>
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Reports</th>
<th class="num">Customers</th>
<th class="num">Interest</th>
<th class="num">Posted</th>
<th class="num">Not yet posted</th>
</tr>
</thead>
<tbody>
@for (b of byBranch; track b.officeName) {
<tr>
<td>
<strong>{{ b.officeName }}</strong>
</td>
<td class="num">{{ b.reports }}</td>
<td class="num">{{ b.customers }}</td>
<td class="num">₹{{ b.interest | number: '1.2-2' }}</td>
<td class="num">₹{{ b.posted | number: '1.2-2' }}</td>
<td class="num">₹{{ b.interest - b.posted | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="ipr-empty">
@if (loading) {
Loading…
} @else {
No interest posting report in this period.
}
</td>
</tr>
}
</tbody>
@if (byBranch.length > 0) {
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td class="num">
<strong>{{ shown.length }}</strong>
</td>
<td class="num">
<strong>{{ totalCustomers }}</strong>
</td>
<td class="num">
<strong>₹{{ totalInterest | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>₹{{ postedInterest | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>₹{{ totalInterest - postedInterest | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
}
</table>
</div>
<div class="ipr-card">
<table class="ipr-table">
<thead>
<tr>
<th>Posting date</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Customers</th>
<th class="num">Interest</th>
<th>Status</th>
<th>Approved</th>
<th>Posted</th>
</tr>
</thead>
<tbody>
@for (r of shown; track r.id) {
<tr>
<td>{{ r.postingDate }}</td>
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">₹{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="ipr-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td class="muted">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="ipr-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
—
}
</td>
<td class="muted">{{ r.postedAt ? (r.postedAt | date: 'dd MMM, HH:mm') : '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,244 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
InterestPostingReportService,
InterestPostingSummaryRow
} from '../interest-posting-report/interest-posting-report.service';
/**
* The head office's view of savings interest across branches: for each posting date, branch and
* product, how many customers were paid and how much, and whether it has been approved and posted.
* Nothing is approved here — each branch approves its own — so it is a report and an Excel export.
*/
@Component({
selector: 'mifosx-interest-posting-summary',
templateUrl: './interest-posting-summary.component.html',
styleUrls: ['../interest-posting-report/interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class InterestPostingSummaryComponent implements OnInit {
private reportService = inject(InterestPostingReportService);
private settingsService = inject(SettingsService);
fromDate = '';
toDate = '';
productId: number | null = null;
loading = false;
errorMessage = '';
rows: InterestPostingSummaryRow[] = [];
ngOnInit(): void {
const today = this.businessDateIso();
this.toDate = today;
this.fromDate = `${today.slice(0, 7)}-01`;
this.load();
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.summary(this.fromDate || undefined, this.toDate || undefined).subscribe({
next: (rows) => {
this.rows = rows ?? [];
this.loading = false;
},
error: (err) => {
this.loading = false;
this.rows = [];
this.errorMessage =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.message ??
'Could not load the interest summary.';
}
});
}
/** Products present in the loaded rows, for the filter. */
get products(): { id: number; name: string }[] {
const seen = new Map<number, string>();
this.rows.forEach((r) => seen.set(r.productId, r.productName));
return [...seen.entries()].map(
([
id,
name
]) => ({ id, name })
);
}
get shown(): InterestPostingSummaryRow[] {
return this.productId ? this.rows.filter((r) => r.productId === this.productId) : this.rows;
}
get totalCustomers(): number {
return this.shown.reduce((sum, r) => sum + (r.accountCount || 0), 0);
}
get totalInterest(): number {
return this.shown.reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get postedInterest(): number {
return this.shown.filter((r) => r.status === 'POSTED').reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get pendingCount(): number {
return this.shown.filter((r) => r.status !== 'POSTED').length;
}
/** Branch totals, for the second table and the export. */
get byBranch(): { officeName: string; customers: number; interest: number; posted: number; reports: number }[] {
const map = new Map<
string,
{ officeName: string; customers: number; interest: number; posted: number; reports: number }
>();
this.shown.forEach((r) => {
const key = r.officeName || '—';
const row = map.get(key) ?? { officeName: key, customers: 0, interest: 0, posted: 0, reports: 0 };
row.customers += r.accountCount || 0;
row.interest += Number(r.totalInterest || 0);
row.posted += r.status === 'POSTED' ? Number(r.totalInterest || 0) : 0;
row.reports += 1;
map.set(key, row);
});
return [...map.values()].sort((a, b) => a.officeName.localeCompare(b.officeName));
}
statusClass(status: string): string {
return (
{
GENERATED: 'ipr-badge--pending',
APPROVED: 'ipr-badge--approved',
POSTED: 'ipr-badge--posted'
}[status] ?? 'ipr-badge--muted'
);
}
exportExcel(): void {
const period = `Posting dates ${this.fromDate || '…'} to ${this.toDate || '…'}`;
const product = this.productId ? this.products.find((p) => p.id === this.productId)?.name : 'All products';
exportWorkbook(
[
{
sheetName: 'By report',
title: 'Savings Interest — Branch-wise Posting',
subtitle: `${period} · ${product}`,
headers: [
'Posting Date',
'Branch',
'Product',
'Customers',
'Interest',
'Status',
'Approved By',
'Approved At',
'Posted At'
],
widths: [
14,
22,
24,
12,
16,
12,
20,
20,
20
],
rows: [
...this.shown.map((r) => [
r.postingDate,
r.officeName,
r.productName,
r.accountCount,
Number(r.totalInterest || 0),
r.status,
(r.approvedBy || '') + (r.approvedOnBehalf ? ' (HO)' : ''),
r.approvedAt || '',
r.postedAt || ''
]),
[
'TOTAL',
'',
'',
this.totalCustomers,
this.totalInterest,
'',
'',
'',
''
]
]
},
{
sheetName: 'By branch',
title: 'Savings Interest — Branch Totals',
subtitle: `${period} · ${product}`,
headers: [
'Branch',
'Reports',
'Customers',
'Interest',
'Posted',
'Not Yet Posted'
],
widths: [
22,
10,
12,
16,
16,
16
],
rows: [
...this.byBranch.map((b) => [
b.officeName,
b.reports,
b.customers,
b.interest,
b.posted,
b.interest - b.posted
]),
[
'TOTAL',
this.shown.length,
this.totalCustomers,
this.totalInterest,
this.postedInterest,
this.totalInterest - this.postedInterest
]
]
}
],
`savings-interest-branch-summary-${this.fromDate}-to-${this.toDate}`
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
}
@@ -52,7 +52,7 @@
}
} @else {
<span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute</span>
}
</div>
}
@@ -62,8 +62,10 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__type">
<mat-label>Rate</mat-label>
<mat-select [(ngModel)]="newRate.rateType">
<mat-option value="CRR">CRR — Form 1</mat-option>
<mat-option value="SLR">SLR — Form 9</mat-option>
<!-- Both ratios are Form 1's. Part B charges section 18 on the base, Part C section
24 on the same base - one form, two parts, not two returns. -->
<mat-option value="CRR">CRR — Form 1 Part B</mat-option>
<mat-option value="SLR">SLR — Form 1 Part C</mat-option>
<!-- Not a reserve ratio, but set by RBI and effective-dated the same way: the share of
savings balances that counts as a demand liability, the rest being time. -->
<mat-option value="SAVINGS_DEMAND">Savings — demand share</mat-option>
@@ -129,11 +131,30 @@
<!-- ═══ GL accounts ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<div class="rrc-gl-head">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<!--
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 of them at once buries the few that matter for the task in hand.
-->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-gl-head__filter">
<mat-label>Configuring</mat-label>
<mat-select [(ngModel)]="formFilter" (selectionChange)="onFormFilterChange()">
@for (f of formFilters; track f.value) {
<mat-option [value]="f.value">{{ f.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<p class="rrc-card__hint">
Every role takes as many accounts as it needs — a bank holds current accounts at several other banks, and each
one counts toward what it maintains. Only postable accounts are listed: a header account's balance is the sum of
its children, so mapping both would count those children twice.
@if (formFilter !== 'ALL') {
Heads that build or net the DTL base are shown for either return, since both charge their rate on that same
base.
}
</p>
@if (unmappedCount) {
@@ -385,6 +385,20 @@ $red: #c5221f;
margin: 0 !important;
}
// Title and the return filter on one line: the filter says what the list below is showing, so it
// belongs beside the heading rather than buried among the roles.
.rrc-gl-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
&__filter {
width: 260px;
}
}
.rrc-remove {
min-width: 0 !important;
padding: 0 8px !important;
@@ -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,
@@ -64,6 +67,80 @@ export class RbiReserveConfigComponent implements OnInit {
/** The roles grouped by the return they feed, in backend order. */
roleGroups: { name: string; label: string; roles: any[] }[] = [];
/**
* Which return's configuration is on screen: Form 1, Form IX, or both together.
*
* 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_IX' = 'ALL';
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
/** Re-slices what is on screen. No round trip - the full set is already held. */
onFormFilterChange(): void {
this.glRoles = this.rolesForScreen();
this.rebuildGroups();
}
/**
* The roles this screen shows: those in its own group, narrowed to the return selected above.
*
* The group scope is widened for Form IX. Its heads were seeded into their own group so they sit
* apart on the backend, and a screen fixed to 'RESERVE' would filter every one of them out before
* the return filter ever ran - leaving the Form IX option selectable and its list empty.
*/
private rolesForScreen(): any[] {
const wanted = this.formFilter === 'FORM_IX' ? 'FORM9' : this.group;
const scoped = wanted ? this.allRoles.filter((r: any) => r.group === wanted) : this.allRoles;
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 & SLR' },
{ value: 'FORM_IX', label: 'Form IX — Assets & Liabilities (R408)' }
];
/**
* Whether a head belongs to the return currently selected.
*
* 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') {
return true;
}
// Form IX's heads are placed by the group they were seeded into, not by usage. Its usages -
// LIABILITY and ASSET - read the same as Form 1's but mean something different: on Form IX they
// say which SIDE OF THE BALANCE SHEET a head sits on, not how it enters a ratio. Sixty heads
// showing up under Form 1's filter would bury the eight that belong there.
const group = role.group || role.roleGroup;
if (group === 'FORM9') {
return this.formFilter === 'FORM_IX';
}
if (this.formFilter === 'FORM_IX') {
return false;
}
// Everything left belongs to Form 1, whichever of its two parts it settles.
return true;
}
/** Which group's roles are on screen. Unused when the screen is scoped to one group. */
activeGroup = '';
rates: any[] = [];
@@ -94,15 +171,18 @@ 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)' },
{ value: 'MEMO', label: 'Memo — shown on the return but totals nothing' }
];
@@ -140,10 +220,11 @@ export class RbiReserveConfigComponent implements OnInit {
}
private applyConfig(config: any): void {
const allRoles = config.glRoles || [];
// Kept so the form filter can re-slice without a round trip.
this.allRoles = config.glRoles || [];
// Scoped to this screen's return before anything else, so every count, warning and Save on the
// page speaks about the roles the user can actually see.
this.glRoles = this.group ? allRoles.filter((r: any) => r.group === this.group) : allRoles;
this.glRoles = this.rolesForScreen();
this.rates = config.rates || [];
this.selection = {};
for (const role of this.glRoles) {
@@ -201,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.'
);
}
@@ -61,12 +61,7 @@
}
</mat-select>
</mat-form-field>
<button
mat-raised-button
color="primary"
[disabled]="savingGl || !isGlDirty(role)"
(click)="saveGl(role)"
>
<button mat-raised-button color="primary" [disabled]="savingGl || !isGlDirty(role)" (click)="saveGl(role)">
{{ savingGl ? 'Saving…' : 'Save' }}
</button>
</div>
@@ -78,8 +73,8 @@
<div class="rrc-card">
<h3 class="rrc-card__title">Financial years</h3>
<p class="rrc-card__hint">
One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep quoting
that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep
quoting that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
</p>
<div class="rrc-tabs" role="tablist">
@@ -37,7 +37,10 @@ interface FieldDef {
@Component({
selector: 'mifosx-tds-config',
templateUrl: './tds-config.component.html',
styleUrls: ['../rbi-reserve-config/rbi-reserve-config.component.scss', './tds-config.component.scss'],
styleUrls: [
'../rbi-reserve-config/rbi-reserve-config.component.scss',
'./tds-config.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
@@ -67,7 +70,12 @@ export class TdsConfigComponent implements OnInit {
readonly rateFields: FieldDef[] = [
{ key: 'thresholdGeneral', label: 'Threshold – general (₹)', kind: 'amount', hint: 'Payee below 60' },
{ key: 'thresholdSenior', label: 'Threshold – senior citizen (₹)', kind: 'amount', hint: '60 at any time in the year' },
{
key: 'thresholdSenior',
label: 'Threshold – senior citizen (₹)',
kind: 'amount',
hint: '60 at any time in the year'
},
{ key: 'ratePanPercentage', label: 'Rate – valid PAN (%)', kind: 'percent' },
{ key: 'rateNoPanPercentage', label: 'Rate – PAN missing / invalid (%)', kind: 'percent' },
{ key: 'lateDeductionPercentage', label: 'Late deduction interest (% per month)', kind: 'percent' },
@@ -96,7 +104,12 @@ export class TdsConfigComponent implements OnInit {
{ value: 'QUARTERLY', label: 'Quarterly' }
]
},
{ key: 'declarationStatementDueDay', label: 'Statement due on day', kind: 'int', hint: 'Of the following month / quarter' }
{
key: 'declarationStatementDueDay',
label: 'Statement due on day',
kind: 'int',
hint: 'Of the following month / quarter'
}
];
readonly statuteFields: FieldDef[] = [
@@ -226,22 +226,18 @@ export class CurrentProductSettingsStepComponent implements OnInit {
const percent = form.get('odExcessLimitPercent');
const penal = form.get('odPenalRate');
percent.setValidators(
required
? [
required ? [
Validators.required,
Validators.min(0.01),
Validators.max(25)
]
: []
] : []
);
penal.setValidators(
required
? [
required ? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
]
: []
] : []
);
percent.updateValueAndValidity({ emitEvent: false });
penal.updateValueAndValidity({ emitEvent: false });
@@ -23,7 +23,9 @@
@if (!isOverdraftAllowed) {
<div class="spt-note">
<span>ℹ️</span>
<span>Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.</span>
<span
>Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.</span
>
</div>
}
@@ -84,8 +84,12 @@ export class CurrentProductTermsStepComponent implements OnInit {
this.CurrentProductTermsForm.patchValue({
interestPostingPeriodType: storedPostingOffered ? storedPosting : defaultPosting,
interestCalculationDaysInYearType: storedDaysInYear === 360 ? 360 : 365,
nominalAnnualInterestRateOverdraft: isEditing ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft : '',
minOverdraftForInterestCalculation: isEditing ? this.CurrentProductsTemplate.minOverdraftForInterestCalculation : '',
nominalAnnualInterestRateOverdraft: isEditing
? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft
: '',
minOverdraftForInterestCalculation: isEditing
? this.CurrentProductsTemplate.minOverdraftForInterestCalculation
: '',
autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true
});
@@ -98,13 +102,11 @@ export class CurrentProductTermsStepComponent implements OnInit {
private applyOverdraftValidators(allowed: boolean) {
const rate = this.CurrentProductTermsForm.get('nominalAnnualInterestRateOverdraft');
rate.setValidators(
allowed
? [
allowed ? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
]
: [Validators.min(0)]
] : [Validators.min(0)]
);
rate.updateValueAndValidity({ emitEvent: false });
}
@@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
const FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Fixed Deposit Interest Paid';
const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
type AccountingRowConfig = {
key: string;
label: string;
@@ -128,6 +132,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
feesReceivableAccountId: [''],
penaltiesReceivableAccountId: [''],
interestPayableAccountId: [''],
savingsInterestPaidAccountId: [''],
escheatLiabilityId: [''],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
@@ -158,6 +163,20 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
return '';
}
private extractNamedGlMapping(
otherGlMappings: any[],
accountName: string,
controlName: string,
options: GLAccount[]
): any[] {
const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
if (match) {
const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
this.fixedDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId);
}
return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
}
private patchAccountingValues(mappings: any) {
this.fixedDepositProductAccountingForm.patchValue({
savingsReferenceAccountId: this.extractAccountId(
@@ -200,7 +219,18 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
glMappings?.liabilities || [],
this.liabilityAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.expenses || [],
FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
),
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -266,10 +296,26 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
const glAccountId = this.fixedDepositProductAccountingForm.get(controlName)?.value;
if (!glAccountId) {
return [];
}
const glCode = options.find((o) => o.id === glAccountId)?.glCode || '';
return glCode ? [{ accountName, glCode }] : [];
};
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
),
...map('expensesOtherGlMappings', this.expenseAccountOptions)
],
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -416,6 +462,13 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'savingsInterestPaidAccountId',
label: 'Fixed Deposit Interest Paid',
inputLabel: 'Fixed deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -456,6 +509,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
savingsInterestPaidAccountId,
...accounting
} = this.fixedDepositProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
@@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
const RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Recurring Deposit Interest Paid';
const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
type AccountingRowConfig = {
key: string;
label: string;
@@ -122,6 +126,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
feesReceivableAccountId: [''],
penaltiesReceivableAccountId: [''],
interestPayableAccountId: [''],
savingsInterestPaidAccountId: [''],
escheatLiabilityId: [''],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
@@ -136,6 +141,20 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
});
}
private extractNamedGlMapping(
otherGlMappings: any[],
accountName: string,
controlName: string,
options: GLAccount[]
): any[] {
const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
if (match) {
const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
this.recurringDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId);
}
return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
}
private patchAccountingValues(mappings: any) {
this.recurringDepositProductAccountingForm.patchValue({
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
@@ -158,7 +177,18 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
glMappings?.liabilities || [],
this.liabilityAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.expenses || [],
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
),
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -224,10 +254,26 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
const glAccountId = this.recurringDepositProductAccountingForm.get(controlName)?.value;
if (!glAccountId) {
return [];
}
const glCode = options.find((o) => o.id === glAccountId)?.glCode || '';
return glCode ? [{ accountName, glCode }] : [];
};
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
),
...map('expensesOtherGlMappings', this.expenseAccountOptions)
],
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -360,11 +406,18 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
label: 'Interest On Recurring Deposits',
inputLabel: 'Interest on recurring deposits',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'savingsInterestPaidAccountId',
label: 'Recurring Deposit Interest Paid',
inputLabel: 'Recurring deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -405,6 +458,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
savingsInterestPaidAccountId,
...accounting
} = this.recurringDepositProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
@@ -144,7 +144,7 @@
</button>
}
@if (savingProductFormValid && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()">
<button mat-raised-button color="primary" [disabled]="submitting" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px"></i>
{{ 'labels.buttons.Submit' | translate }}
</button>
@@ -70,6 +70,10 @@ export class CreateSavingProductComponent {
savingProductsTemplate: any;
accountingRuleData: string[] = [];
selectedTabIndex = 0;
/** Guards against repeat clicks on Submit: the product name is unique in the database, so a
* second request for the same form fails with a duplicate key error even though the first one
* created the product. */
submitting = false;
/**
* @param {ActivatedRoute} route Activated Route.
@@ -162,8 +166,11 @@ export class CreateSavingProductComponent {
}
submit() {
if (this.submitting) {
return;
}
this.submitting = true;
const savingProduct = this.prepareSavingProductPayload();
console.log('Saving product submit payload:', savingProduct);
this.productsService.createSavingProduct(savingProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
@@ -181,6 +188,11 @@ export class CreateSavingProductComponent {
{ relativeTo: this.route }
);
}
},
error: () => {
// the error itself is reported by the global error handler; just let the user correct the
// form and try again
this.submitting = false;
}
});
}
@@ -88,6 +88,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
liabilityAccountOptions: GLAccount[] = [];
expenseAccountOptions: GLAccount[] = [];
incomeAccountOptions: GLAccount[] = [];
/** Savings Interest Paid sits under Expenses and offers expense GLs; it nets to nil at each posting. */
paidAccountOptions: GLAccount[] = [];
assetAccountOptions: GLAccount[] = [];
paymentTypeOptions: any[] = [];
/** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account
@@ -114,6 +116,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
this.paidAccountOptions = this.expenseAccountOptions;
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
this.buildAccountingSectionConfigs();
@@ -201,26 +204,30 @@ export class SavingProductAccountingStepComponent implements OnInit {
// Backend serializes as 'otherGlMappingsMap'; fall back to 'otherGlMappings' for compatibility
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
this.setOtherGlMappings(
'liabilitiesOtherGlMappings',
// Payable is a liability row, saved under liabilities; Paid an expense row, saved under
// expenses. Products saved before either move carry them in the other bucket, so both buckets
// are read and the named entries are stripped out of either one's "Other" rows.
const fromLiabilities = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.liabilities || [],
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
'savingsInterestPayableAccountId',
this.liabilityAccountOptions
),
this.liabilityAccountOptions
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.paidAccountOptions
);
this.setOtherGlMappings(
'expensesOtherGlMappings',
this.extractNamedGlMapping(
glMappings?.expenses || [],
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
(glMappings?.expenses || []).filter(
(mapping: any) => mapping?.accountName !== SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME
),
this.expenseAccountOptions
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.paidAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -336,11 +343,13 @@ export class SavingProductAccountingStepComponent implements OnInit {
),
...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
],
// The backend finds Savings Interest Paid by name in any bucket; it sits under expenses
// because that is where the screen shows it.
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
this.paidAccountOptions
),
...mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions)
],
@@ -378,14 +387,16 @@ export class SavingProductAccountingStepComponent implements OnInit {
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
addControl('savingsReferenceAccountId', true);
addControl('savingsControlAccountId', true);
// The interest expense: every interest posting debits it for the product's total.
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true);
// Savings Interest Payable / Paid are offered under Cash too, but the backend only persists the
// core mappings for CASH_BASED, so these two ride along in otherGlMappings (see getOtherGlMappings)
// rather than as named accounting params, which would be silently dropped. Optional by design.
addControl('savingsInterestPayableAccountId', false);
addControl('savingsInterestPaidAccountId', false);
// rather than as named accounting params, which would be silently dropped.
// Required: interest posting refuses a product without them rather than skipping their legs.
addControl('savingsInterestPayableAccountId', true);
addControl('savingsInterestPaidAccountId', true);
// A real accounting param, unlike the two above, so it rides out as itself rather than
// inside otherGlMappings. Optional: a product saved without one still closes.
addControl('roundingAdjustmentAccountId', false);
@@ -522,11 +533,14 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
// Credited with the product's total interest at posting and drawn straight back down,
// so it closes each posting at nil.
key: 'savingsInterestPayableAccountId',
label: 'Savings Interest Payable',
inputLabel: 'Savings interest payable',
controlName: 'savingsInterestPayableAccountId',
options: this.liabilityAccountOptions
options: this.liabilityAccountOptions,
required: true
},
{
key: 'interestPayableAccountId',
@@ -555,6 +569,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
pillClass: 'amber',
rows: [
{
// The interest cost: debited for the product's total at every posting.
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
@@ -563,11 +578,13 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
// A pass-through between the payable and the members that closes each posting at nil.
key: 'savingsInterestPaidAccountId',
label: 'Savings Interest Paid',
inputLabel: 'Savings interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
options: this.paidAccountOptions,
required: true
},
{
// Cash has no paise, so a closing balance of 9,883.79 is handed over as 9,883 and the
@@ -0,0 +1,261 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="f9-container">
<!--
═══ Date prompt ═══
Asked before anything is fetched: the return reports a month end, and which month it covers
follows from this one field.
-->
@if (awaitingDate) {
<div class="f9-prompt">
<h2 class="f9-prompt__title">Form IX — Return on Assets and Liabilities</h2>
<p class="f9-prompt__sub">
Return code R408, filed monthly. Every figure is a closing balance as at the month end, in rupees thousands.
</p>
<div class="f9-prompt__row">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f9-prompt__date">
<mat-label>Reporting date</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="reportingDate" />
</mat-form-field>
<button mat-raised-button color="primary" (click)="generate()">Generate Form IX</button>
</div>
@if (chosenMonth) {
<p class="f9-prompt__hint">
Month: <strong>{{ chosenMonth }}</strong>
</p>
}
<div class="f9-prompt__quick">
<span>Jump to:</span>
<button mat-stroked-button (click)="setToMonthEnd()">Month end</button>
</div>
@if (errorMsg) {
<div class="f9-alert f9-alert--error">{{ errorMsg }}</div>
}
</div>
} @else if (loading) {
<div class="f9-loading">Computing from the ledger…</div>
} @else if (errorMsg) {
<div class="f9-alert f9-alert--error">{{ errorMsg }}</div>
} @else if (data) {
<div class="f9-bar f9-noprint">
<span class="f9-bar__date">
As at <strong>{{ data.reportingDate }}</strong> · {{ data.unit }}
</span>
<button mat-stroked-button (click)="changeDate()">Change date</button>
<button mat-stroked-button (click)="printReturn()">
<i class="fas fa-print"></i>
Print
</button>
<button mat-raised-button color="primary" [disabled]="generating" (click)="exportWorkbook()">
<i class="fas fa-file-excel"></i>
{{ generating ? 'Generating…' : 'RBI workbook' }}
</button>
</div>
<div class="f9-sheet" id="f9-printable">
<div class="f9-sheet__head">
<div class="f9-sheet__title">{{ data.formName }}</div>
<div class="f9-sheet__ref">{{ data.statutoryReference }} · Return code {{ data.returnCode }}</div>
<div class="f9-sheet__unit">As at {{ data.reportingDate }} · {{ data.unit }}</div>
</div>
<!--
═══ Reconciliation ═══
At the top, not the bottom. A return whose two sides disagree has something unmapped or
mapped twice, and a reviewer who learns that after reading the statement has already
believed the statement.
-->
<div class="f9-recon" [class.f9-recon--off]="!data.reconciliation.balanced">
<div class="f9-recon__cell">
<span class="f9-recon__label">Total Liabilities</span>
<span class="f9-recon__value">{{ amount(data.reconciliation.totalLiabilities) }}</span>
</div>
<div class="f9-recon__cell">
<span class="f9-recon__label">Total Assets</span>
<span class="f9-recon__value">{{ amount(data.reconciliation.totalAssets) }}</span>
</div>
<div class="f9-recon__cell">
<span class="f9-recon__label">Difference</span>
<span class="f9-recon__value" [class.f9-neg]="!data.reconciliation.balanced">
{{ amount(data.reconciliation.difference) }}
</span>
<span class="f9-recon__sub">
{{
data.reconciliation.balanced
? 'The two sides agree'
: rupees(data.reconciliation.differenceInRupees) + ' in rupees'
}}
</span>
</div>
</div>
@if (data.caveats.length) {
<div class="f9-alert f9-alert--warn">
<strong>Check before filing.</strong>
<ul class="f9-caveats">
@for (caveat of data.caveats; track caveat) {
<li>{{ caveat }}</li>
}
</ul>
</div>
}
<!-- ═══ A. Liabilities ═══ -->
<table class="f9-table">
<thead>
<tr>
<th class="f9-table__particulars">A. Liabilities in India</th>
<th class="f9-table__num">Rs. in Thousands</th>
</tr>
</thead>
<tbody>
@for (line of data.liabilityLines; track line.configKey) {
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
<td class="f9-table__particulars">
{{ line.label }}
@if (!line.configured) {
<span class="f9-badge">Not mapped</span>
}
<!-- Why a line reads as it does, where the figure alone would mislead — a loss
mapped under Balance of Profit but reported on the asset side. -->
@if (line.note) {
<span class="f9-line__note">{{ line.note }}</span>
}
</td>
<td class="f9-table__num">{{ amount(line.amount) }}</td>
</tr>
<!-- The accounts behind the head. On a "specify" head these rows ARE the sheet the
RBI workbook asks for — one row per account, under its own name. -->
@for (account of line.accounts; track $index) {
<tr class="f9-subline" [class.f9-subline--excluded]="account.excluded">
<td>
<span class="f9-subline__code">{{ account.glCode }}</span>
{{ account.name }}
@if (account.excluded) {
<span class="f9-subline__excluded">excluded — {{ account.excluded }}</span>
}
@if (isPartial(account)) {
<span class="f9-subline__portion">{{ account.weightPercentage }}% share</span>
}
</td>
<td class="f9-table__num">{{ amount(account.balance) }}</td>
</tr>
}
}
<tr class="f9-total">
<td>Total Demand and Time Liabilities (4 + 5 + 6)</td>
<td class="f9-table__num">{{ amount(total('totalDemandAndTime')) }}</td>
</tr>
<tr class="f9-total f9-total--grand">
<td>TOTAL LIABILITIES</td>
<td class="f9-table__num">{{ amount(total('totalLiabilities')) }}</td>
</tr>
</tbody>
</table>
<!-- ═══ B. Assets ═══ -->
<table class="f9-table">
<thead>
<tr>
<th class="f9-table__particulars">B. Assets in India</th>
<th class="f9-table__num">Rs. in Thousands</th>
</tr>
</thead>
<tbody>
@for (line of data.assetLines; track line.configKey) {
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
<td class="f9-table__particulars">
{{ line.label }}
@if (!line.configured) {
<span class="f9-badge">Not mapped</span>
}
<!-- Why a line reads as it does, where the figure alone would mislead — a loss
mapped under Balance of Profit but reported on the asset side. -->
@if (line.note) {
<span class="f9-line__note">{{ line.note }}</span>
}
</td>
<td class="f9-table__num">{{ amount(line.amount) }}</td>
</tr>
<!-- A head whose sub-rows carry the opposite sign says so on its own line. Three
negative balances under a positive total otherwise read as an error rather than
as the ledger side the figure was carried from. -->
@if (line.accountsMirrorHead && line.accounts.length) {
<tr class="f9-subline f9-subline--mirror">
<td colspan="2">
As the ledger holds them — these balances carry the opposite sign and add to the figure above
</td>
</tr>
}
@for (account of line.accounts; track $index) {
<tr class="f9-subline" [class.f9-subline--excluded]="account.excluded">
<td>
<span class="f9-subline__code">{{ account.glCode }}</span>
{{ account.name }}
@if (account.excluded) {
<span class="f9-subline__excluded">excluded — {{ account.excluded }}</span>
}
@if (isPartial(account)) {
<span class="f9-subline__portion">{{ account.weightPercentage }}% share</span>
}
</td>
<td class="f9-table__num">{{ amount(account.balance) }}</td>
</tr>
}
}
<tr class="f9-total f9-total--grand">
<td>TOTAL ASSETS</td>
<td class="f9-table__num">{{ amount(total('totalAssets')) }}</td>
</tr>
</tbody>
</table>
<!-- ═══ Stated splits and explanations ═══ -->
@if (data.memoLines.length) {
<table class="f9-table">
<thead>
<tr>
<th class="f9-table__particulars">Stated splits and explanations — entering no total</th>
<th class="f9-table__num">Rs. in Thousands</th>
</tr>
</thead>
<tbody>
@for (line of data.memoLines; track line.configKey) {
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
<td class="f9-table__particulars">
{{ line.label }}
@if (!line.configured) {
<span class="f9-badge">Not mapped</span>
}
</td>
<td class="f9-table__num">{{ amount(line.amount) }}</td>
</tr>
}
<tr class="f9-line">
<td class="f9-table__particulars">Percentage of overdues to total loans and advances</td>
<td class="f9-table__num">{{ percent(total('overduePercentage')) }}</td>
</tr>
<tr class="f9-line">
<td class="f9-table__particulars">Priority sector advances as a percentage of total loans</td>
<td class="f9-table__num">{{ percent(total('prioritySectorPercentage')) }}</td>
</tr>
</tbody>
</table>
}
<div class="f9-sign">
<div class="f9-sign__line">AUTHORISED SIGNATORY</div>
</div>
</div>
}
</div>
@@ -0,0 +1,395 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1353a0;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-900: #202124;
$green: #137333;
$amber: #b06000;
$red: #c5221f;
.f9-container {
max-width: 1180px;
margin: 0 auto;
padding: 20px 16px;
}
// ── Date prompt ──
// Centred and roomy rather than a strip at the top: nothing is on screen yet, so this is the
// screen, and a filer choosing the month the return covers should not be hunting for the field.
.f9-prompt {
max-width: 640px;
margin: 48px auto;
padding: 28px 32px;
background: #fff;
border: 1px solid $border;
border-radius: 12px;
text-align: center;
box-shadow: 0 1px 3px rgb(60 64 67 / 12%);
&__title {
margin: 0 0 8px;
font-size: 19px;
font-weight: 600;
color: $gray-900;
}
&__sub {
margin: 0 0 22px;
font-size: 13px;
line-height: 1.6;
color: $gray-500;
}
&__row {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
&__date {
width: 200px;
}
&__hint {
margin: 16px 0 0;
font-size: 12.5px;
color: $primary;
}
&__quick {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
margin-top: 18px;
font-size: 12px;
color: $gray-500;
}
}
.f9-loading {
padding: 48px;
text-align: center;
color: $gray-500;
font-size: 13px;
}
.f9-alert {
padding: 12px 16px;
border-radius: 8px;
font-size: 12.5px;
margin-bottom: 16px;
line-height: 1.6;
&--error {
background: #fce8e6;
color: $red;
}
&--warn {
background: #fff8e1;
color: $amber;
border: 1px solid #e0c56b;
}
}
.f9-caveats {
margin: 8px 0 0 18px;
padding: 0;
}
// ── Toolbar ──
.f9-bar {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 18px;
&__date {
flex: 1;
font-size: 13px;
color: $primary;
font-weight: 500;
}
}
// ── The form itself ──
.f9-sheet {
background: #fff;
border: 1px solid $gray-900;
padding: 20px;
&__head {
text-align: center;
border-bottom: 2px solid $gray-900;
padding-bottom: 12px;
margin-bottom: 16px;
}
&__title {
font-weight: 700;
font-size: 16px;
text-transform: uppercase;
color: $gray-900;
}
&__ref {
font-size: 11.5px;
color: $gray-500;
margin-top: 4px;
}
&__unit {
font-size: 11.5px;
color: $primary;
font-weight: 600;
margin-top: 4px;
}
}
// ── Reconciliation ──
// Green when the two sides agree, red when they do not. The colour is the point: a return that
// does not balance has something unmapped or mapped twice, and that has to be visible before
// anyone reads a figure off the statement below.
.f9-recon {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: $gray-200;
border: 1px solid $green;
border-radius: 8px;
overflow: hidden;
margin-bottom: 18px;
&--off {
border-color: $red;
}
&__cell {
background: #fff;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $gray-500;
font-weight: 600;
}
&__value {
font-size: 20px;
font-weight: 600;
color: $gray-900;
font-variant-numeric: tabular-nums;
}
&__sub {
font-size: 11px;
color: $gray-500;
}
}
.f9-neg {
color: $red;
}
// ── Tables ──
.f9-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
margin-bottom: 20px;
th,
td {
border: 1px solid $gray-200;
padding: 6px 10px;
vertical-align: top;
}
th {
background: $gray-50;
font-weight: 700;
font-size: 11.5px;
color: $gray-900;
border-color: $gray-900;
text-align: left;
}
&__particulars {
width: 72%;
}
&__num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
width: 1%;
min-width: 140px;
}
// Qualified with the element so a numeric heading is not dragged back to the left by the bare
// `th` rule above, which matches at the same specificity and would win on source order alone.
th#{&}__num {
text-align: right;
}
}
.f9-line {
&--unconfigured {
background: #fffdf8;
}
// Set on its own line rather than inline: it explains the figure beside it, and read as a
// trailing clause on the label it looks like part of the head's name.
&__note {
display: block;
margin-top: 3px;
font-size: 10.5px;
font-style: italic;
color: $amber;
}
}
.f9-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 6px;
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $amber;
background: #fff3cd;
border: 1px solid #e0c56b;
border-radius: 3px;
}
// An account behind a head. These rows are the "specify" sheets of the RBI workbook — one row
// per account, under its own name — so they are indented under their head rather than hidden.
.f9-subline {
td {
padding-top: 4px;
padding-bottom: 4px;
font-size: 11.5px;
color: $gray-500;
border-bottom: none;
}
td:first-child {
padding-left: 28px;
}
&--excluded td {
background: #fffdf5;
}
// The line that warns the rows beneath it read the other way round. Set apart from the balances
// themselves, since it explains them rather than being one of them.
&--mirror td {
padding-left: 28px;
font-style: italic;
font-size: 10.5px;
color: $gray-500;
background: #fbfcfe;
}
&__code {
font-family: 'Roboto Mono', monospace;
font-size: 10.5px;
background: $gray-50;
border: 1px solid $border;
border-radius: 4px;
padding: 1px 5px;
margin-right: 6px;
}
&__portion {
margin-left: 8px;
padding: 0 5px;
font-size: 10px;
font-weight: 600;
color: $amber;
background: #fff3cd;
border-radius: 3px;
}
&__excluded {
margin-left: 8px;
font-size: 10px;
font-weight: 600;
color: $amber;
}
}
.f9-total td {
font-weight: 700;
background: #fafafa;
color: $gray-900;
}
.f9-total--grand td {
background: #e8f0fe;
font-size: 13px;
}
.f9-sign {
margin-top: 56px;
text-align: right;
&__line {
display: inline-block;
border-top: 1px solid $gray-900;
padding-top: 6px;
font-size: 12px;
min-width: 230px;
text-align: center;
}
}
@media print {
.f9-noprint {
display: none !important;
}
.f9-container {
max-width: none;
padding: 0;
}
.f9-table thead {
display: table-header-group;
}
.f9-table tr {
break-inside: avoid;
}
// Backgrounds carry meaning here — an unmapped head is tinted, a reconciliation that fails is
// red — so they must survive the print engine's default of dropping them to save ink.
.f9-sheet,
.f9-table,
.f9-table th,
.f9-table td,
.f9-recon,
.f9-badge,
.f9-alert {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -0,0 +1,492 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { catchError, of } from 'rxjs';
import * as ExcelJS from 'exceljs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RbiCircularsService } from '../rbi-circulars.service';
import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
/** One account behind a head — a row of the form's "specify" sheets. */
export interface Form9Account {
glAccountId: number;
glCode: string;
name: string;
/** In thousands, as the form prints it. */
balance: number | null;
/** The same balance in rupees, which is what reconciles against the ledger. */
balanceInRupees?: number | null;
weightPercentage?: number | null;
liabilityClass?: string | null;
excluded?: 'disabled' | 'header';
}
/** One head of the return. */
export interface Form9Line {
configKey: string;
label: string;
usage: 'LIABILITY' | 'ASSET' | 'MEMO';
configured: boolean;
amount: number | null;
amountInRupees: number | null;
accounts: Form9Account[];
/** Why a line reads as it does, where that is not obvious — a loss moved to the asset side. */
note?: string;
/**
* Whether the accounts below carry the opposite sign to the head.
*
* True on a figure the return carries across sides: a loss shown as a positive at item 12 is made
* up of balances that are negative where the ledger holds them. Without saying so, three negative
* rows under a positive head read as an error.
*/
accountsMirrorHead?: boolean;
}
export interface Form9Reconciliation {
totalLiabilities: number;
totalAssets: number;
difference: number;
differenceInRupees: number;
balanced: boolean;
}
export interface Form9Return {
formCode: string;
formName: string;
returnCode: string;
statutoryReference: string;
frequency: string;
unit: string;
reportingDate: string;
monthEnd: string;
monthComplete: boolean;
note: string;
liabilityLines: Form9Line[];
assetLines: Form9Line[];
memoLines: Form9Line[];
totals: Record<string, number | null>;
reconciliation: Form9Reconciliation;
caveats: string[];
computable: boolean;
}
/**
* Form IX — the Return on Assets and Liabilities a co-operative bank files monthly, code R408.
*
* A different return from Form 9 (SLR) despite the name, and it does not share that screen's
* machinery: this one reports the whole balance sheet rather than one ratio, in rupees thousands
* rather than rupees, and its two sides have to be shown against each other.
*
* The reconciliation is on the face of the screen whether or not it holds. The filed return this
* was built from balanced its sides by subtracting the difference from the side that was already
* short and printed "DIFF ADJUSTED" — so the one thing a reviewer needed to know was the one thing
* the report would not say.
*/
@Component({
selector: 'mifosx-form9-return',
templateUrl: './form9-return.component.html',
styleUrls: ['./form9-return.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class Form9ReturnComponent {
private rbiService = inject(RbiCircularsService);
data: Form9Return | null = null;
loading = false;
errorMsg = '';
generating = false;
/**
* Nothing is fetched until a date is chosen. Which month the return covers follows from that one
* field, and a return that opened on today's month would be a month still running — reported as
* though it had closed.
*/
awaitingDate = true;
/** Today, as a date input formats it — also the latest date the return may be run for. */
readonly today = new Date().toISOString().slice(0, 10);
reportingDate = this.today;
generate(): void {
if (!this.reportingDate) {
this.errorMsg = 'Choose a reporting date first.';
return;
}
// A balance is summed as everything up to a date, so a future one does not fail and does not
// come back empty — it returns today's figures under a heading that has not happened.
if (this.reportingDate > this.today) {
this.errorMsg = `The reporting date (${this.reportingDate}) has not happened yet — today is ${this.today}.`;
return;
}
this.awaitingDate = false;
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.rbiService
.getForm9Return(this.reportingDate)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute the return from the ledger.';
return of(null);
})
)
.subscribe((res: Form9Return | null) => {
this.loading = false;
this.data = res;
});
}
/** Back to the date prompt, so a filer can run a different month. */
changeDate(): void {
this.awaitingDate = true;
this.data = null;
this.errorMsg = '';
}
/** The month the chosen date falls in, shown beside the picker. */
get chosenMonth(): string {
if (!this.reportingDate) {
return '';
}
const d = new Date(this.reportingDate + 'T00:00:00');
return d.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' });
}
/** Sets the date to the end of the month it currently falls in, capped at today. */
setToMonthEnd(): void {
const d = this.reportingDate ? new Date(this.reportingDate + 'T00:00:00') : new Date();
const end = new Date(d.getFullYear(), d.getMonth() + 1, 0);
const iso = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`;
// Capped: the current month's end has not arrived, and the return would be refused.
this.reportingDate = iso > this.today ? this.today : iso;
}
amount(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return value.toLocaleString('en-IN', { maximumFractionDigits: 0 });
}
/** Rupee figures keep their paise — they are what reconciles against the ledger. */
rupees(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
percent(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return `${value.toFixed(2)}%`;
}
/** A head taking only part of an account — the figure below is not that account's balance. */
isPartial(account: Form9Account): boolean {
const weight = account.weightPercentage;
return weight !== null && weight !== undefined && weight < 100;
}
/** One of the form's stated totals. */
total(key: string): number | null {
return this.data?.totals?.[key] ?? null;
}
printReturn(): void {
window.print();
}
/**
* The return as a workbook, laid out as the RBI template is: General Information, then Liabilities
* and Assets, then a sheet per "specify" list, then Signatory Information.
*
* A workbook that reorganised the form would have to be read back against the screen line by line
* before anyone could trust it.
*/
async exportWorkbook(): Promise<void> {
if (!this.data) {
return;
}
this.generating = true;
try {
const wb = newWorkbook();
this.addGeneralInformation(wb);
this.addSideSheet(wb, 'Liabilities', 'A. Liabilities in India', this.data.liabilityLines);
this.addSideSheet(wb, 'Assets', 'B. Assets in India', this.data.assetLines);
this.addSpecifySheets(wb);
this.addMemoSheet(wb);
this.addSignatorySheet(wb);
await downloadWorkbook(wb, `RBI_Form_IX_${this.data.reportingDate}`);
} finally {
this.generating = false;
}
}
private addGeneralInformation(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('General Information');
sheet.columns = [
{ width: 42 },
{ width: 46 }];
addBanner(sheet, 'General Information', 'B');
const start = sheet.rowCount + 1;
this.keyValue(sheet, 'Return Name', this.data!.formName);
this.keyValue(sheet, 'Return Code', this.data!.returnCode);
this.keyValue(sheet, 'Period End Date', this.data!.reportingDate);
this.keyValue(sheet, 'Reporting Frequency', this.data!.frequency);
this.keyValue(sheet, 'Unit', this.data!.unit);
// Whether the return balances belongs here, at the top, not buried under the figures: a
// reviewer who reads it after the statement has already believed the statement.
this.keyValue(
sheet,
'Validation Status',
this.data!.reconciliation.balanced
? 'Balanced'
: `NOT BALANCED — liabilities exceed assets by ${this.amount(this.data!.reconciliation.difference)} thousand`
);
boxAllCells(sheet, start, sheet.rowCount, 2);
if (this.data!.caveats.length) {
sheet.addRow([]);
const head = sheet.addRow(['Check before filing:']);
head.font = { bold: true, color: { argb: 'FFB06000' } };
for (const caveat of this.data!.caveats) {
const row = sheet.addRow([caveat]);
sheet.mergeCells(row.number, 1, row.number, 2);
row.alignment = { wrapText: true };
row.font = { size: 9, color: { argb: 'FFB06000' } };
}
}
}
private addSideSheet(wb: ExcelJS.Workbook, name: string, title: string, lines: Form9Line[]): void {
const sheet = wb.addWorksheet(name);
sheet.columns = [
{ width: 64 },
{ width: 20 },
{ width: 22 }];
addBanner(sheet, title, 'C');
const start = sheet.rowCount + 1;
const head = sheet.addRow([
'Particulars',
'Rs. in Thousands',
'Rs. (as in ledger)'
]);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const line of lines) {
const row = sheet.addRow([
line.label,
line.configured ? line.amount : 'Not mapped',
line.configured ? line.amountInRupees : ''
]);
row.getCell(1).alignment = { wrapText: true };
row.getCell(2).numFmt = '#,##0';
row.getCell(3).numFmt = '#,##0.00';
// The accounts behind the head, indented under it, so every figure traces to the ledger.
for (const account of line.accounts) {
const portion = this.isPartial(account) ? ` (${account.weightPercentage}% share)` : '';
const sub = sheet.addRow([
` ${account.glCode} — ${account.name}${portion}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`,
account.excluded ? 'Excluded' : account.balance,
account.excluded ? '' : account.balanceInRupees
]);
sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } };
sub.getCell(2).numFmt = '#,##0';
sub.getCell(3).numFmt = '#,##0.00';
}
}
sheet.addRow([]);
const totalKey = name === 'Liabilities' ? 'totalLiabilities' : 'totalAssets';
const total = sheet.addRow([
`TOTAL ${name.toUpperCase()}`,
this.total(totalKey),
''
]);
total.font = { bold: true };
total.getCell(2).numFmt = '#,##0';
boxAllCells(sheet, start, sheet.rowCount, 3);
}
/**
* One sheet per "specify" list, exactly as the RBI workbook carries them.
*
* Each account mapped to the head IS a row on its sheet, under its own name — the same accounts
* already listed under the head on the side sheet, gathered here because the template asks for
* them on their own page.
*/
private addSpecifySheets(wb: ExcelJS.Workbook): void {
const sheets: { name: string; title: string; key: string }[] = [
{ name: 'Other Funds And Reserves', title: 'Other Funds And Reserves - Specify', key: 'FORM9_L2_G_OTHER_FUNDS' },
{
name: 'Demand Liabilities',
title: 'Demand Liabilities Others Borrowing From Banks - Specify',
key: 'FORM9_L4_C_BORROWINGS_DEMAND'
},
{ name: 'Other Demand Liabilities', title: 'Other Demand Liabilities - Specify', key: 'FORM9_L4_D_OTHER_DEMAND' },
{
name: 'Time Liabilities',
title: 'Time Liabilities Others Borrowing From Banks - Specify',
key: 'FORM9_L5_C_BORROWINGS_TIME'
},
{ name: 'Other Time Liabilities', title: 'Other Time Liabilities - Specify', key: 'FORM9_L5_E_OTHER_TIME' },
{ name: 'Others Investments', title: 'Others Investments - Specify', key: 'FORM9_A4_H_OTHER_INVESTMENTS' }
];
const all = [
...this.data!.liabilityLines,
...this.data!.assetLines
];
for (const spec of sheets) {
const line = all.find((l) => l.configKey === spec.key);
const sheet = wb.addWorksheet(spec.name);
sheet.columns = [
{ width: 8 },
{ width: 52 },
{ width: 20 }];
addBanner(sheet, spec.title, 'C');
const start = sheet.rowCount + 1;
const head = sheet.addRow([
'Sr No.',
'Particulars',
'Amount'
]);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
const accounts = (line?.accounts ?? []).filter((a) => !a.excluded);
accounts.forEach((account, i) => {
const row = sheet.addRow([
i + 1,
account.name,
account.balance
]);
row.getCell(3).numFmt = '#,##0';
});
// An empty sheet still prints its heading and a nil total: the template carries the sheet
// whether or not the bank has anything to put on it.
const total = sheet.addRow([
'',
'Total',
line?.configured ? line.amount : 0
]);
total.font = { bold: true };
total.getCell(3).numFmt = '#,##0';
boxAllCells(sheet, start, sheet.rowCount, 3);
}
}
/** The stated splits and explanations — on the form's face, entering no total. */
private addMemoSheet(wb: ExcelJS.Workbook): void {
if (!this.data!.memoLines.length) {
return;
}
const sheet = wb.addWorksheet('Explanations');
sheet.columns = [
{ width: 64 },
{ width: 20 }];
addBanner(sheet, 'Stated splits and explanations', 'B');
const start = sheet.rowCount + 1;
const head = sheet.addRow([
'Particulars',
'Rs. in Thousands'
]);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const line of this.data!.memoLines) {
const row = sheet.addRow([
line.label,
line.configured ? line.amount : 'Not mapped'
]);
row.getCell(1).alignment = { wrapText: true };
row.getCell(2).numFmt = '#,##0';
}
const overdue = sheet.addRow([
'Percentage of overdues to total loans and advances',
this.percent(this.total('overduePercentage'))
]);
overdue.font = { bold: true };
const priority = sheet.addRow([
'Priority sector advances as a percentage of total loans',
this.percent(this.total('prioritySectorPercentage'))
]);
priority.font = { bold: true };
boxAllCells(sheet, start, sheet.rowCount, 2);
}
/**
* The signatory block, left blank for signing.
*
* Deliberately not filled from anywhere: who signs a filed return and on what date is a decision
* the bank makes when it files, and pre-filling a name would put a signature on a form nobody had
* agreed to sign.
*/
private addSignatorySheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Signatory Information');
sheet.columns = [
{ width: 24 },
{ width: 34 },
{ width: 34 }];
addBanner(sheet, 'Signatory Information', 'C');
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)));
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);
}
boxAllCells(sheet, start, sheet.rowCount, 3);
}
private keyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void {
const row = sheet.addRow([
label,
value
]);
row.getCell(1).font = { bold: true };
row.getCell(1).fill = solidFill(XL.keyCell);
}
}
@@ -10,14 +10,7 @@ import { inject } from '@angular/core';
import { Observable, catchError, 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, newWorkbook, solidFill, XL } from '../oss-workbook';
/** One GL account behind a return line. */
export interface OssAccount {
+11
View File
@@ -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]);
@@ -69,12 +69,24 @@ const routes: Routes = [
loadComponent: () => import('./reserve-return/form1-crr.component').then((m) => m.Form1CrrComponent),
data: { title: 'Form 1 — Cash Reserve Ratio', breadcrumb: 'Form 1 (CRR)' }
},
// Form 9 (SLR) has no route of its own. Section 24 is settled on the statutory Form 1 at Part C,
// charged on the same liabilities Part B charges the cash reserve on. A second screen computing
// the same ratio from a month-end base disagreed with it, and both were headed Form 9 — whichever
// a filer read, they had no way to know the other said something else.
{
// SLR return, filed at the end of each month.
path: 'rbi/form9',
loadComponent: () => import('./reserve-return/form9-slr.component').then((m) => m.Form9SlrComponent),
data: { title: 'Form 9 — Statutory Liquidity Ratio', breadcrumb: 'Form 9 (SLR)' }
// Form IX — the Return on Assets and Liabilities, code R408, filed monthly.
//
// A DIFFERENT return from Form 9 (SLR) above despite the name. That one reports one ratio;
// this one reports the whole balance sheet, in rupees thousands, and its two sides must
// agree. Its own route rather than a tab on rbi/form9 for exactly that reason — they answer
// different questions and share no figures.
path: 'rbi/form9-return',
loadComponent: () => import('./form9-return/form9-return.component').then((m) => m.Form9ReturnComponent),
data: { title: 'Form IX — Return on Assets and Liabilities', breadcrumb: 'Form IX (R408)' }
}
// The statutory Form 1 has no route of its own: it is a tab on rbi/form1. Both are Form 1 for
// one bank and one period, and two routes made a filer guess which held which — one could be
// read while the other was believed to be on screen.
])
];
+89 -1
View File
@@ -71,11 +71,17 @@ export class RbiCircularsService {
* @param reportingDate yyyy-MM-dd. Defaults to the current business date on the backend.
* @param officeId Optional — scopes every figure to one office.
*/
getForm1(reportingDate?: string, officeId?: number): Observable<any> {
getForm1(reportingDate?: string, baseDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
// The base date can be given outright. The 45-day lag lands where it lands, and a bank reading
// an earlier fortnight — or correcting a filing — needs to name the date the base is struck at
// rather than work backwards from it. Left blank it is derived from the lag as before.
if (baseDate) {
params = params.set('baseDate', baseDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
@@ -97,6 +103,44 @@ export class RbiCircularsService {
return this.http.get('/rbireservereturn/form9', { params });
}
/**
* Form 9 in the shape it is filed — the statutory statement of compliance with section 24.
*
* A different statement from `getForm9`, not a reformatting: three dated columns rather than one,
* and the ratio charged on the last Friday of the second preceding fortnight rather than on the
* month end itself.
*/
getForm9Statutory(reportingDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http.get('/rbireservereturn/form9-statutory', { params });
}
/**
* Form IX — the Return on Assets and Liabilities a co-operative bank files monthly, code R408.
*
* A different return from `getForm9` despite the name: that one reports the SLR ratio, this one
* reports the whole balance sheet. Every figure comes back in rupees thousands, with the rupee
* figure alongside it so a line can still be reconciled against the ledger.
*
* @param reportingDate yyyy-MM-dd — any date in the month; the return reports that month's end.
*/
getForm9Return(reportingDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http.get('/rbireservereturn/form9-return', { params });
}
/**
* OSS 1 — Return on Assets and Liabilities, computed live from the ledger.
*
@@ -121,6 +165,50 @@ export class RbiCircularsService {
return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) });
}
/**
* Form 1 as a co-operative bank files it — Banking Regulation (Co-operative Societies) Rules
* 1966, Rule 5, sections 18(1) and 24(3).
*
* A different statement from `getForm1`, not a reformatting: three columns rather than one, both
* ratios settled on the same liabilities, and those liabilities taken from the last Friday of the
* second preceding fortnight rather than a 45-day lag.
*/
getForm1Statutory(
reportingDate?: string,
firstColumnDate?: string,
secondColumnDate?: string,
officeId?: number,
firstBaseDate?: string,
secondBaseDate?: string
): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
// Both fortnight closes can be given outright. A bank filing August wants 15/08 and 31/08 on
// the face of the form whatever today is, and one date cannot produce a pair that spans a month
// boundary or skips a fortnight — which a late or corrected filing needs.
if (firstColumnDate) {
params = params.set('firstColumnDate', firstColumnDate);
}
if (secondColumnDate) {
params = params.set('secondColumnDate', secondColumnDate);
}
// The base each column's ratios are charged on. Left blank the rules fix it — the last Friday
// of that column's second preceding fortnight — so a filer only names one where the rule
// produces a date their ledger cannot answer, such as a period before the bank migrated.
if (firstBaseDate) {
params = params.set('firstBaseDate', firstBaseDate);
}
if (secondBaseDate) {
params = params.set('secondBaseDate', secondBaseDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http.get('/rbireservereturn/form1-statutory', { params });
}
// ── Saved returns ──
/**
@@ -8,9 +8,11 @@
import { Component, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { Observable, catchError, of } from 'rxjs';
import * as ExcelJS from 'exceljs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ReserveReturnBase } from './reserve-return.base';
import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
import { Form1Part, Form1Statutory, ReserveReturnBase } from './reserve-return.base';
/**
* RBI Form 1 — Cash Reserve Ratio, filed twice a month.
@@ -18,11 +20,20 @@ import { ReserveReturnBase } from './reserve-return.base';
* The return compares the CRR required on an earlier fortnight's Demand and Time Liabilities
* against the cash and bank balances held today. The two sides are deliberately read on different
* dates — see the basis panel on the screen, which states both.
*
* The statutory Form 1 — the return as a co-operative bank actually files it, three dated columns
* with both ratios settled on one base — is a tab on this same screen rather than a page of its
* own. Both are Form 1 for the same bank and the same period; splitting them across two routes
* meant a filer had to know which URL held which, and could read one while believing they were
* looking at the other.
*/
@Component({
selector: 'mifosx-form1-crr',
templateUrl: './reserve-return.component.html',
styleUrls: ['./reserve-return.component.scss'],
styleUrls: [
'./reserve-return.component.scss',
'./form1-statutory.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
@@ -30,14 +41,107 @@ import { ReserveReturnBase } from './reserve-return.base';
})
export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
protected readonly exportStem = 'RBI_Form1_CRR';
protected readonly formCode = 'FORM_1';
// The statutory form's code, because that is what this screen saves and therefore what its saved
// list must ask for. Left as FORM_1 the list filtered on a code nothing writes any more and came
// back empty, which reads as "nothing has ever been saved" rather than as a mismatch.
protected readonly formCode = 'FORM_1_STATUTORY';
/** Returns filed before the screen moved to the statutory form, so they stay in the list. */
protected override readonly legacyFormCodes = ['FORM_1'];
override readonly hasStatutoryForm = true;
override readonly hasBaseDate = true;
/**
* The statutory form is the only view of Form 1.
*
* There were two: a working report charging the ratio on a 45-day lag, and the statutory form
* charging it on the last Friday of the second preceding fortnight. Both were headed Form 1, both
* were computed from the same ledger, and they disagreed — because the rules they follow differ.
* One screen showing two different answers to "what is our CRR position" is worse than no screen:
* whichever a filer read, they had no way to know the other existed and said something else. What
* gets filed is the statutory form, so that is what the screen shows.
*/
override activeTab: 'report' | 'statutory' | 'saved' = 'statutory';
protected override fetchStatutory(): Observable<any> {
return this.rbiService.getForm1Statutory(
this.statutoryDate,
this.statutoryFirstColumn || undefined,
this.statutorySecondColumn || undefined,
undefined,
this.statutoryFirstBase || undefined,
this.statutorySecondBase || undefined
);
}
protected fetch(reportingDate: string): Observable<any> {
return this.rbiService.getForm1(reportingDate);
return this.rbiService.getForm1(reportingDate, this.baseDate || undefined);
}
ngOnInit(): void {
this.load();
// No load() here — the screen asks for a reporting date first, because which fortnight the
// return covers and which base date it reads follow from that one field.
this.loadSaved();
}
/**
* The fortnight the chosen date falls in, shown beside the picker so a filer sees which period
* they are about to run. Fortnights close on the 15th and on the month end.
*/
get statutoryFortnight(): string {
if (!this.statutoryDate) {
return '';
}
const d = new Date(this.statutoryDate + 'T00:00:00');
const first = d.getDate() <= 15;
const end = first ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
const month = d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' });
return `${first ? 1 : 16}–${end} ${month}`;
}
/**
* Fills both columns with a whole month's fortnight closes — 15th and month end — which is the
* pair a filed return almost always carries.
*/
fillStatutoryMonth(): void {
const d = this.statutoryDate ? new Date(this.statutoryDate + 'T00:00:00') : new Date();
const year = d.getFullYear();
const month = d.getMonth() + 1;
const monthEnd = new Date(year, month, 0).getDate();
this.statutoryFirstColumn = this.asDate(year, month, 15);
// Filling the CURRENT month would put a month end that has not arrived into the second column,
// and the form would be refused the moment it was run. Capped at today, so the quick fill
// always produces a pair that can actually be reported.
const filled = this.asDate(year, month, monthEnd);
this.statutorySecondColumn = filled > this.today ? this.today : filled;
this.statutoryDate = this.statutorySecondColumn;
// The bases follow the closes, so a quick fill leaves a complete form rather than two dates
// and two empty boxes the filer has to work out for themselves.
this.onStatutoryColumnChange('first');
this.onStatutoryColumnChange('second');
}
/** Clears both columns and the bases that follow from them. */
clearStatutoryColumns(): void {
this.statutoryFirstColumn = '';
this.statutorySecondColumn = '';
this.statutoryFirstBase = '';
this.statutorySecondBase = '';
}
private asDate(year: number, month: number, day: number): string {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
/**
* Form 1 saves the statutory form, because that is the only Form 1 this screen shows.
*
* Saved under its own form code rather than FORM_1: a saved return is rendered from its stored
* snapshot, and a statutory statement filed under the working report's code would be opened by
* the reader built for the other shape.
*/
protected override saveableReturn(): { formCode: string; reportingDate: string; report: any } | null {
return this.statutory
? { formCode: this.statutory.formCode, reportingDate: this.statutory.reportingDate, report: this.statutory }
: null;
}
}
@@ -0,0 +1,510 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1353a0;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-900: #202124;
$green: #137333;
$amber: #b06000;
$red: #c5221f;
.f1s-container {
max-width: 1180px;
margin: 0 auto;
padding: 20px 16px;
}
.f1s-loading {
padding: 36px;
text-align: center;
color: $gray-500;
font-size: 13px;
}
.f1s-alert {
padding: 12px 16px;
border-radius: 8px;
font-size: 12.5px;
margin-bottom: 16px;
line-height: 1.6;
&--error {
background: #fce8e6;
color: $red;
}
&--warn {
background: #fef7e0;
color: $amber;
}
}
.f1s-unmet {
margin: 8px 0 0 18px;
padding: 0;
}
// ── Date prompt ──
// Shown before anything is fetched. Centred and roomy rather than a strip at the top, because at
// this point choosing the date is the whole of the screen's job.
.f1s-prompt {
max-width: 640px;
margin: 48px auto;
background: #fff;
border: 1px solid $border;
border-radius: 12px;
padding: 32px;
text-align: center;
&__title {
margin: 0 0 6px;
font-size: 18px;
font-weight: 700;
color: $gray-900;
}
&__sub {
margin: 0 0 24px;
font-size: 12.5px;
color: $gray-500;
line-height: 1.7;
}
// The date pairs. Each row holds two, and the rows stack — so the closes sit directly above the
// bases they produce, and a filer reads down a column rather than across a wrapped line.
&__row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
& + & {
margin-top: 10px;
}
// The bases are filled from the closes above rather than typed, so they are set back a shade -
// enough to read as "these follow from those" without looking disabled, since they can be
// changed and sometimes must be.
&--derived {
opacity: 0.85;
}
}
&__date {
width: 220px;
}
// The button on its own line under the pairs, centred on them rather than trailing the last one.
&__go {
margin-top: 16px;
display: flex;
justify-content: center;
}
&__hint {
margin: 14px 0 0;
font-size: 12.5px;
color: $primary;
}
&__quick {
margin-top: 18px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
color: $gray-500;
button {
font-size: 12px !important;
min-width: 0 !important;
}
}
}
// The strip above the sheet when the statutory form is shown as a tab: says what period is on
// screen and offers the way back to the date prompt.
.f1s-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
&__date {
font-size: 12.5px;
font-weight: 600;
color: $primary;
}
}
// ── Toolbar ──
.f1s-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
&__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: $gray-900;
}
&__ref {
margin: 2px 0 0;
font-size: 12px;
color: $gray-500;
}
&__actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
i {
margin-right: 6px;
}
}
}
.f1s-date {
width: 180px;
}
// ── The sheet ──
// Bordered and squared off rather than card-like: this is a statutory return that gets printed and
// signed, and it should read on screen as the same document that comes out of the printer.
.f1s-sheet {
background: #fff;
border: 1px solid $gray-900;
padding: 20px;
&__head {
text-align: center;
border-bottom: 2px solid $gray-900;
padding-bottom: 12px;
margin-bottom: 16px;
}
&__title {
font-size: 15px;
font-weight: 700;
color: $gray-900;
text-transform: uppercase;
}
&__ref {
margin-top: 4px;
font-size: 12px;
color: $gray-500;
}
}
.f1s-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th,
td {
border: 1px solid $gray-200;
padding: 6px 10px;
vertical-align: top;
}
th {
background: $gray-50;
font-weight: 700;
font-size: 11.5px;
color: $gray-900;
text-align: center;
border-color: $gray-900;
}
&__particulars {
text-align: left !important;
width: 52%;
}
&__num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
// A column heading is a date, not a figure, and sits centred over the column it heads. Qualified
// with `th` so it beats `&__num` above on specificity rather than on source order.
th#{&}__num {
text-align: center;
}
// The second line of a heading — what the column IS, under the date it carries. Without it the
// three dates read as three closes, and the base the ratios are charged on looks like one more
// reporting date.
&__colnote {
display: block;
margin-top: 2px;
font-weight: 400;
font-size: 9.5px;
letter-spacing: 0.02em;
text-transform: uppercase;
color: $gray-500;
}
// The date the column's ratios are charged on. In the heading rather than in a column of its own,
// so a reader sees which base produced the required amounts below without the base's balances
// sitting beside the closes' as though they were a third period.
&__colbase {
display: block;
margin-top: 1px;
font-weight: 400;
font-size: 9px;
letter-spacing: 0.02em;
color: $primary;
}
}
// Part banner — PART A / PART B / PART C.
.f1s-part td {
background: $primary;
color: #fff;
font-weight: 700;
font-size: 12px;
letter-spacing: 0.03em;
border-color: $primary;
}
// Section caption inside a part, e.g. "(a) Demand Liabilities".
.f1s-heading td {
background: $gray-50;
font-weight: 700;
}
.f1s-total td {
font-weight: 700;
background: #e8f0fe;
}
.f1s-total--result td {
background: #e6f4ea;
}
// A shortfall reads as one rather than as a number with a minus sign.
.f1s-neg {
color: $red;
}
.f1s-subnote td {
font-size: 11px;
font-style: italic;
color: $gray-500;
background: #fffdf5;
}
// The form nests to three levels — (a), then (i), then the figure.
.f1s-indent-1 {
padding-left: 26px !important;
}
.f1s-indent-2 {
padding-left: 46px !important;
}
.f1s-badge {
margin-left: 8px;
font-size: 10px;
font-weight: 600;
background: #fef7e0;
color: $amber;
border-radius: 20px;
padding: 1px 8px;
white-space: nowrap;
}
// ── Day by day ──
//
// Outside the form's own sheet, because it is not part of what gets filed: the form reports two
// closes, and this is the working behind them.
.f1s-daily {
margin-top: 24px;
background: #fff;
border: 1px solid $border;
border-radius: 10px;
padding: 18px 20px;
&__title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 700;
color: $gray-900;
}
// A day short is a breach, and on thirty rows it is exactly what the eye skips. Said in the
// heading so it is read before the table rather than found in it.
&__flag {
display: inline-block;
margin-left: 10px;
padding: 2px 8px;
font-size: 10.5px;
font-weight: 700;
color: $red;
background: #fce8e6;
border-radius: 4px;
}
&__hint {
margin: 0 0 14px;
font-size: 11.5px;
color: $gray-500;
}
&__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
// The base a day was charged on. Set back because it repeats for every day of a fortnight and
// would otherwise compete with the figures it qualifies.
&__base {
color: $gray-500;
font-size: 11px;
}
// A day the bank's own liabilities moved. The whole point of carrying a daily figure is to find
// these, and on thirty rows of near-identical numbers they are invisible unless marked.
&__moved {
font-weight: 700;
color: $primary;
background: #f0f6ff;
}
&__table {
font-size: 11.5px;
th {
font-size: 10.5px;
}
}
&--short td {
background: #fff6f6;
}
}
.f1s-stat {
border: 1px solid $border;
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 2px;
&__label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $gray-500;
font-weight: 600;
}
&__value {
font-size: 17px;
font-weight: 600;
color: $gray-900;
font-variant-numeric: tabular-nums;
}
&__sub {
font-size: 10.5px;
color: $gray-500;
}
}
.f1s-sign {
margin-top: 56px;
text-align: right;
&__line {
display: inline-block;
border-top: 1px solid $gray-900;
padding-top: 6px;
font-size: 12px;
font-weight: 600;
min-width: 220px;
text-align: center;
}
}
.f1s-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: $gray-900;
color: #fff;
font-size: 12.5px;
padding: 10px 18px;
border-radius: 8px;
z-index: 1000;
}
// ── Print ──
// The browser's print dialog is also its save-as-PDF, so one stylesheet serves both. The sheet is
// already laid out as the filed form, so printing only has to drop the controls and keep the
// shading that carries meaning.
@media print {
.f1s-noprint {
display: none !important;
}
.f1s-container {
max-width: none;
padding: 0;
}
.f1s-sheet {
border: none;
padding: 0;
}
.f1s-table thead {
display: table-header-group;
}
.f1s-table tr {
break-inside: avoid;
}
.f1s-toast {
display: none !important;
}
.f1s-container,
.f1s-sheet,
.f1s-table,
th,
td {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -1,43 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ReserveReturnBase } from './reserve-return.base';
/**
* RBI Form 9 — Statutory Liquidity Ratio, filed at the end of the month.
*
* Unlike Form 1 there is no lag: both the liability base and the SLR-eligible investments are read
* as at month end, so the return states the position actually held at close. Any date the user
* picks resolves to that month's end.
*/
@Component({
selector: 'mifosx-form9-slr',
templateUrl: './reserve-return.component.html',
styleUrls: ['./reserve-return.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class Form9SlrComponent extends ReserveReturnBase implements OnInit {
protected readonly exportStem = 'RBI_Form9_SLR';
protected readonly formCode = 'FORM_9';
protected fetch(reportingDate: string): Observable<any> {
return this.rbiService.getForm9(reportingDate);
}
ngOnInit(): void {
this.load();
this.loadSaved();
}
}
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,88 @@
-->
<div class="rr-container">
@if (loading) {
<!--
═══ Date prompt ═══
Asked before anything is fetched: the return is dated, and which fortnight it covers, which
lagged date the base is read at and which rate is in force all follow from that one field.
-->
<!--
On a return whose statutory form IS the return, the working report is never fetched, so the tab
bar cannot live inside the `@if (data)` block below — it would never render and the statutory
form would have no way back to the saved list. Shown here instead, above everything.
-->
@if (hasStatutoryForm) {
<div class="rr-tabs rr-noprint">
<button class="rr-tab" [class.rr-tab--on]="activeTab === 'statutory'" (click)="showTab('statutory')">
<i class="fas fa-file-alt"></i> {{ statutoryTabLabel }}
</button>
<button class="rr-tab" [class.rr-tab--on]="activeTab === 'saved'" (click)="showTab('saved')">
<i class="fas fa-bookmark"></i> Saved returns
@if (savedReturns.length) {
<span class="rr-tab__count">{{ savedReturns.length }}</span>
}
</button>
</div>
}
<!--
The whole working-report chain below — prompt, loading, error, statement — belongs to returns
that have no statutory form. Gating only its first branch left the rest of the chain live, so
Form 1 fell through to a loading state for a fetch it never makes and sat there spinning.
-->
@if (hasStatutoryForm) {
<!-- Nothing: the statutory form and the saved list render further down, on their own. -->
} @else if (awaitingDate) {
<div class="f1s-prompt">
<h2 class="f1s-prompt__title">{{ promptTitle }}</h2>
<p class="f1s-prompt__sub">
Choose the date the return is made up to. The base is read at an earlier date than the balances maintained, and
the return states both on its face once it runs.
</p>
<!--
The base date is entered outright where the return has one. The 45-day lag lands where it
lands, and a bank reading an earlier fortnight — or correcting a filing — needs to name the
date rather than work backwards from it. Left blank it is derived from the lag as before.
-->
<div class="f1s-prompt__row">
@if (hasBaseDate) {
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Base date (optional)</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="baseDate" />
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Reporting date</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="reportingDate" />
</mat-form-field>
<button mat-raised-button color="primary" (click)="generate()">Generate report</button>
</div>
@if (chosenFortnight) {
<p class="f1s-prompt__hint">
Fortnight: <strong>{{ chosenFortnight }}</strong>
@if (hasBaseDate && !baseDate) {
· base will be derived from the 45-day lag
}
</p>
}
<!-- A filed return is almost always dated to a fortnight close, so both are one click away. -->
<div class="f1s-prompt__quick">
<span>Jump to:</span>
<button mat-stroked-button (click)="setToFortnightClose('first')">15th</button>
<button mat-stroked-button (click)="setToFortnightClose('second')">Month end</button>
@if (hasBaseDate && baseDate) {
<button mat-stroked-button (click)="baseDate = ''">Clear base date</button>
}
</div>
@if (errorMsg) {
<div class="f1s-alert f1s-alert--error">{{ errorMsg }}</div>
}
</div>
} @else if (loading) {
<div class="rr-loading">Computing from the ledger…</div>
} @else if (errorMsg) {
<div class="rr-alert rr-alert--error">{{ errorMsg }}</div>
@@ -27,11 +108,19 @@
</div>
<div class="rr-header__actions">
<!-- Both dates stay editable on the report itself, so a filer can move either without
going back to the prompt and losing the figures they were reading. -->
@if (hasBaseDate) {
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-date">
<mat-label>Base date</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="baseDate" (change)="onDateChange()" />
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-date">
<mat-label>Reporting date</mat-label>
<input matInput type="date" [(ngModel)]="reportingDate" (change)="onDateChange()" />
<input matInput type="date" [max]="today" [(ngModel)]="reportingDate" (change)="onDateChange()" />
</mat-form-field>
<button mat-stroked-button (click)="resetToToday()">Today</button>
<button mat-stroked-button (click)="changeDate()">Change date</button>
<button mat-stroked-button (click)="printReturn()">
<i class="fas fa-print"></i>
Print
@@ -54,6 +143,10 @@
a whole statement and a month of daily rows.
-->
<div class="rr-tabs rr-noprint">
<!-- Where the return has a statutory form, that form IS the return: showing a second,
differently-based statement of the same period beside it meant two screens headed Form 1
carrying different figures, and a filer had to know which one was fileable. The working
view stays for the returns that have no statutory form of their own. -->
<button class="rr-tab" [class.rr-tab--on]="activeTab === 'report'" (click)="showTab('report')">
<i class="fas fa-file-invoice"></i> Report
</button>
@@ -152,12 +245,20 @@
<!-- ═══ Lines ═══ -->
<div class="rr-card">
<!--
Two dated columns rather than one "Amount" against each line's own date. The return is
built from figures struck at two different dates — the base at its lagged date, the
holdings at the reporting date — and with one column a reader cannot tell a real movement
from the two dates simply being different. Every line is read at both, and the line's own
figure is marked so it is clear which of the two the return actually uses.
-->
<table class="rr-table">
<thead>
<tr>
<th>Particulars</th>
<th>As at</th>
<th class="rr-table__num">Amount (INR)</th>
<th class="rr-table__num">{{ data.baseDate || 'Base date' }}</th>
<th class="rr-table__num">{{ data.currentDate || 'Reporting date' }}</th>
<th class="rr-table__num">Movement</th>
</tr>
</thead>
<tbody>
@@ -175,10 +276,41 @@
<span class="rr-badge rr-badge--unmapped">Not configured</span>
}
</td>
<td class="rr-line__date">{{ line.asOnDate }}</td>
<td class="rr-table__num">
<!-- The cell the return's own figure comes from is tinted, so which date each line
is actually computed at stays visible now that both are shown. -->
<td class="rr-table__num" [class.rr-cell--used]="line.asOnDate === data.baseDate">
@if (line.configured) {
{{ amount(line.amount) }}
{{
amount(
line.baseAmount !== undefined
? line.baseAmount
: line.asOnDate === data.baseDate
? line.amount
: null
)
}}
} @else {
<span class="rr-dash">—</span>
}
</td>
<td class="rr-table__num" [class.rr-cell--used]="line.asOnDate === data.currentDate">
@if (line.configured) {
{{
amount(
line.currentAmount !== undefined
? line.currentAmount
: line.asOnDate === data.currentDate
? line.amount
: null
)
}}
} @else {
<span class="rr-dash">—</span>
}
</td>
<td class="rr-table__num" [class.rr-neg]="(line.movement || 0) < 0">
@if (line.movement !== undefined && line.movement !== null) {
{{ amount(line.movement) }}
} @else {
<span class="rr-dash">—</span>
}
@@ -200,7 +332,10 @@
it this head takes, so the two can be reconciled without opening the ledger. -->
@if (isPartial(account)) {
<span class="rr-subline__portion">
{{ account.weightPercentage }}% of {{ amount(account.accountBalance) }}
<!-- The weight is one number, but the balance it applies to differs by
column. Naming only one would leave the other column's figure looking
as though it did not follow from the stated share. -->
{{ account.weightPercentage }}% of {{ portionOf(account) }}
</span>
}
<!-- Which head the portion falls under, on a liability line that carries both. -->
@@ -208,8 +343,33 @@
<span class="rr-subline__class">{{ account.liabilityClass | titlecase }}</span>
}
</td>
<td></td>
<td class="rr-table__num">{{ amount(account.balance) }}</td>
<!-- The account under both dates, matching the line above it. A head that moved
is only useful if the account that moved it can be seen. -->
<td class="rr-table__num" [class.rr-cell--used]="line.asOnDate === data.baseDate">
@if (
account.baseBalance !== undefined ? account.baseBalance !== null : line.asOnDate === data.baseDate
) {
{{ amount(account.baseBalance !== undefined ? account.baseBalance : account.balance) }}
} @else {
<span class="rr-dash">—</span>
}
</td>
<td class="rr-table__num" [class.rr-cell--used]="line.asOnDate === data.currentDate">
@if (
account.currentBalance !== undefined
? account.currentBalance !== null
: line.asOnDate === data.currentDate
) {
{{ amount(account.currentBalance !== undefined ? account.currentBalance : account.balance) }}
} @else {
<span class="rr-dash">—</span>
}
</td>
<td class="rr-table__num" [class.rr-neg]="(account.movement || 0) < 0">
@if (account.movement !== undefined && account.movement !== null) {
{{ amount(account.movement) }}
}
</td>
</tr>
}
}
@@ -219,16 +379,19 @@
<td>{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL)</td>
<td></td>
<td class="rr-table__num">{{ amount(data.requiredAmount) }}</td>
<td></td>
</tr>
<tr class="rr-total">
<td>{{ data.rateType }} maintained</td>
<td></td>
<td class="rr-table__num">{{ amount(data.maintainedAmount) }}</td>
<td></td>
</tr>
<tr class="rr-total" [class.rr-total--bad]="isShortfall">
<td>{{ isShortfall ? 'Shortfall' : 'Surplus' }}</td>
<td></td>
<td class="rr-table__num">{{ amount(shortfallAmount) }}</td>
<td></td>
</tr>
}
</tbody>
@@ -345,6 +508,342 @@
}
}
<!--
═══ Statutory Form 1 ═══
The return as a co-operative bank files it: three dated columns, Part A establishing the
liabilities, then Parts B and C settling the cash reserve and the liquid assets on that same
base. Outside the `@if (data)` block because it computes from its own date and does not depend
on the fortnightly return above having succeeded.
-->
@if (activeTab === 'statutory') {
@if (statutoryAwaitingDate) {
<div class="f1s-prompt">
<h2 class="f1s-prompt__title">{{ statutoryTitle }}</h2>
<p class="f1s-prompt__sub">{{ statutorySubtitle }}</p>
@if (statutoryTakesTwoColumns) {
<!--
A fortnightly return: both closes are entered outright, and both are required. A bank
filing August wants 15/08 and 31/08 on the face of the form whatever today is, and one
date cannot give a pair that spans a month boundary or skips a fortnight — which a late
or corrected filing needs. Deriving them silently opened the form on whatever fortnight
today fell in, with nothing on its face to say the dates had been guessed.
-->
<div class="f1s-prompt__row">
<!-- Capped at today: a close that has not happened reports today's figures under its own
heading, so the picker does not offer one. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>First column</mat-label>
<input
matInput
type="date"
[max]="today"
[(ngModel)]="statutoryFirstColumn"
(ngModelChange)="onStatutoryColumnChange('first')"
required
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Second column</mat-label>
<input
matInput
type="date"
[max]="today"
[(ngModel)]="statutorySecondColumn"
(ngModelChange)="onStatutoryColumnChange('second')"
required
/>
</mat-form-field>
</div>
<!--
The base each column's ratios are charged on. Filled from the rule as soon as a close is
entered — the matching close one month earlier — 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 requirement whose base nobody can see cannot be
checked. Editable because a bank that migrated part-way through has a base the rule
produces and the ledger cannot answer, and the ratio then reads nil against real
liabilities.
-->
<div class="f1s-prompt__row f1s-prompt__row--derived">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Base for first column</mat-label>
<input matInput type="date" [max]="statutoryFirstColumn || today" [(ngModel)]="statutoryFirstBase" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Base for second column</mat-label>
<input matInput type="date" [max]="statutorySecondColumn || today" [(ngModel)]="statutorySecondBase" />
</mat-form-field>
</div>
<!-- On its own line, under both pairs. Sharing a row with the last pair left it hanging
off one corner of a 2×2 grid, reading as a field's own button rather than the form's. -->
<div class="f1s-prompt__go">
<button
mat-raised-button
color="primary"
[disabled]="!statutoryFirstColumn || !statutorySecondColumn"
(click)="generateStatutory()"
>
Generate
</button>
</div>
@if (statutoryFirstColumn && statutorySecondColumn) {
<p class="f1s-prompt__hint">
Made up to <strong>{{ statutorySecondColumn }}</strong
>. The base dates above are the matching close one month earlier — change either
one if the bank files on a different base.
</p>
} @else {
<p class="f1s-prompt__hint">Enter both fortnight closing dates — the base dates fill in from them.</p>
}
<!-- A filed return almost always carries one month's two closes, so both fill in one click. -->
<div class="f1s-prompt__quick">
<span>Fill:</span>
<button mat-stroked-button (click)="fillStatutoryMonth()">This month's 15th &amp; month end</button>
@if (statutoryFirstColumn || statutorySecondColumn) {
<button mat-stroked-button (click)="clearStatutoryColumns()">Clear</button>
}
</div>
} @else {
<!-- A monthly return: the month settles which columns the form prints, so one date is
enough. Asking for two here would let a filer name a pair that is not a month. -->
<div class="f1s-prompt__row">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Reporting date</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="statutoryDate" required />
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="!statutoryDate" (click)="generateStatutory()">
Generate
</button>
</div>
@if (statutoryDate) {
<p class="f1s-prompt__hint">
The return reports the month ending on or before <strong>{{ statutoryDate }}</strong
>, and charges the ratio on the matching close one month earlier.
</p>
}
}
@if (statutoryError) {
<div class="f1s-alert f1s-alert--error">{{ statutoryError }}</div>
}
</div>
} @else if (statutoryLoading) {
<div class="f1s-loading">Computing from the ledger…</div>
} @else if (statutoryError) {
<div class="f1s-alert f1s-alert--error">{{ statutoryError }}</div>
} @else if (statutory) {
<div class="f1s-bar rr-noprint">
<span class="f1s-bar__date">Made up to {{ statutory.reportingDate }}</span>
<button mat-stroked-button (click)="changeStatutoryDate()">Change date</button>
<!-- This is the form that gets filed, so it has to be able to leave the screen. Print and
Export were on the working report only, and removing that tab took them with it. -->
<button mat-stroked-button (click)="printReturn()">
<i class="fas fa-print"></i>
Print
</button>
<button mat-stroked-button [disabled]="saving" (click)="saveCurrent()">
<i class="fas fa-bookmark"></i>
{{ saving ? 'Saving…' : 'Save' }}
</button>
<button mat-raised-button color="primary" [disabled]="generating" (click)="exportStatutory()">
<i class="fas fa-file-excel"></i>
{{ generating ? 'Generating…' : 'Export' }}
</button>
</div>
<!-- Identified so printing can lift the form alone. window.print() on the page takes the whole
app shell — nav bars, tabs, toolbar — and laid the form out around them. -->
<div class="f1s-sheet" id="f1s-printable">
<div class="f1s-sheet__head">
<div class="f1s-sheet__title">{{ statutory.formName }}</div>
<div class="f1s-sheet__ref">{{ statutory.statutoryReference }}</div>
</div>
@if (statutory.unmetRequirements.length) {
<div class="f1s-alert f1s-alert--warn">
<strong>Check before filing.</strong>
<ul class="f1s-unmet">
@for (reason of statutory.unmetRequirements; track reason) {
<li>{{ reason }}</li>
}
</ul>
</div>
}
<table class="f1s-table">
<thead>
<tr>
<th class="f1s-table__particulars">Particulars</th>
<!-- Each column is a fortnight close, with the date its ratios are charged on named
beneath it. The base used to be a column of its own, where its balances sat
beside the closes' and read as a third period the bank was reporting rather than
as the figures behind the requirement. -->
@for (col of statutory.columns; track col; let i = $index) {
<th class="f1s-table__num">
{{ col }}
<span class="f1s-table__colnote">fortnight close</span>
@if (statutory.baseColumns?.[i]) {
<span class="f1s-table__colbase">charged on {{ statutory.baseColumns[i] }}</span>
}
</th>
}
</tr>
</thead>
<tbody>
@for (part of statutoryParts; track part.title) {
<tr class="f1s-part">
<td [attr.colspan]="statutory.columns.length + 1">{{ part.title }}</td>
</tr>
@for (line of part.lines; track line.label) {
@if (line.heading) {
<tr class="f1s-heading">
<td [attr.colspan]="statutory.columns.length + 1">{{ line.label }}</td>
</tr>
} @else {
<tr [class.f1s-total]="line.total">
<td [class]="'f1s-indent-' + (line.indent || 0)">
{{ line.label }}
@if (line.configKey && line.configured === false) {
<span class="f1s-badge">Not mapped</span>
}
</td>
@for (v of line.values; track $index) {
<td class="f1s-table__num">{{ amount(v) }}</td>
}
</tr>
<!-- The base-date row carries a different date per column, and the whole
requirement hangs off those dates, so they are printed rather than inferred. -->
@if (line.baseDates?.length) {
<tr class="f1s-subnote">
<td>Base dates — the matching close one month earlier</td>
@for (d of line.baseDates; track $index) {
<td class="f1s-table__num">{{ d }}</td>
}
</tr>
}
}
}
@if (part.surplus?.length) {
<tr class="f1s-total f1s-total--result">
<td>Surplus / (Shortfall)</td>
@for (s of part.surplus; track $index) {
<td class="f1s-table__num" [class.f1s-neg]="isShort(s)">{{ amount(s) }}</td>
}
</tr>
}
}
</tbody>
</table>
<div class="f1s-sign">
<div class="f1s-sign__line">AUTHORISED SIGNATORY</div>
</div>
</div>
<!--
═══ Day by day ═══
Both ratios are reckoned over the period, not on the two closes alone — a bank that held
nothing for a fortnight and topped up on the close has a form whose columns pass. This is
the position the compliance actually turns on, and the first thing an inspector asks for.
-->
@if (statutory.daily?.days?.length) {
<div class="f1s-daily">
<h3 class="f1s-daily__title">
Day by day — {{ statutory.daily!.from }} to {{ statutory.daily!.to }}
@if (statutory.daily!.crrShortDays || statutory.daily!.slrShortDays) {
<span class="f1s-daily__flag">
@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.
}
</span>
}
</h3>
<p class="f1s-daily__hint">
Each day is charged on its own base, which moves at the fortnight boundary. A shortfall shows as a negative.
</p>
<div class="f1s-daily__stats">
<div class="f1s-stat">
<span class="f1s-stat__label">CRR average held</span>
<span class="f1s-stat__value">{{ amount(statutory.daily!.crrAverage) }}</span>
<span class="f1s-stat__sub">
lowest {{ amount(statutory.daily!.crrLowest) }} on {{ statutory.daily!.crrLowestOn }}
</span>
</div>
<div class="f1s-stat">
<span class="f1s-stat__label">SLR average held</span>
<span class="f1s-stat__value">{{ amount(statutory.daily!.slrAverage) }}</span>
<span class="f1s-stat__sub">
lowest {{ amount(statutory.daily!.slrLowest) }} on {{ statutory.daily!.slrLowestOn }}
</span>
</div>
<div class="f1s-stat">
<span class="f1s-stat__label">Days counted</span>
<span class="f1s-stat__value">{{ statutory.daily!.dayCount }}</span>
<span class="f1s-stat__sub">to {{ statutory.daily!.to }}</span>
</div>
</div>
<table class="f1s-table f1s-daily__table">
<thead>
<tr>
<th rowspan="2" class="f1s-table__particulars">Date</th>
<!-- The liabilities, before the ratios. Two of them, because they are not the same
figure: the day's own move daily, the base holds still for the whole fortnight,
and it is the base the requirement is charged on. Without both, a table of
identical rows gives no way to see a day the bank's liabilities moved. -->
<th colspan="3" class="f1s-table__num">Net demand &amp; time liabilities</th>
<th colspan="3" class="f1s-table__num">Section 18 — CRR</th>
<th colspan="3" class="f1s-table__num">Section 24 — SLR</th>
</tr>
<tr>
<th class="f1s-table__num">On the day</th>
<th class="f1s-table__num">Charged on</th>
<th class="f1s-table__num">Base amount</th>
<th class="f1s-table__num">Required</th>
<th class="f1s-table__num">Maintained</th>
<th class="f1s-table__num">Surplus / (short)</th>
<th class="f1s-table__num">Required</th>
<th class="f1s-table__num">Maintained</th>
<th class="f1s-table__num">Surplus / (short)</th>
</tr>
</thead>
<tbody>
@for (day of statutory.daily!.days; track day.date) {
<tr [class.f1s-daily--short]="day.crrSurplus < 0 || day.slrSurplus < 0">
<td>{{ day.date }}</td>
<!-- A day the bank's own liabilities moved is marked, because that is the thing
this column exists to show and on thirty near-identical rows it is exactly
what the eye goes past. -->
<td class="f1s-table__num" [class.f1s-daily__moved]="dailyOwnMoved(day, $index)">
{{ day.ownLiabilities === undefined ? '—' : amount(day.ownLiabilities) }}
</td>
<td class="f1s-table__num f1s-daily__base">{{ day.baseDate }}</td>
<td class="f1s-table__num f1s-daily__base">
{{ day.baseLiabilities === undefined ? '—' : amount(day.baseLiabilities) }}
</td>
<td class="f1s-table__num">{{ amount(day.crrRequired) }}</td>
<td class="f1s-table__num">{{ amount(day.crrMaintained) }}</td>
<td class="f1s-table__num" [class.f1s-neg]="day.crrSurplus < 0">{{ amount(day.crrSurplus) }}</td>
<td class="f1s-table__num">{{ amount(day.slrRequired) }}</td>
<td class="f1s-table__num">{{ amount(day.slrMaintained) }}</td>
<td class="f1s-table__num" [class.f1s-neg]="day.slrSurplus < 0">{{ amount(day.slrSurplus) }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
<!--
═══ Saved returns ═══
Outside the `@if (data)` block so the list stays reachable even when the live return cannot be
@@ -364,7 +863,10 @@
ledger, so a figure already reported cannot change underneath it.
</p>
@if (data?.computable) {
<!-- Gated on there being a return on screen at all, not on the working report specifically:
on Form 1 the statutory form is the return, and keying this to `data` hid the save row on
the very screen whose return most needs keeping. -->
@if (canSave) {
<div class="rr-save-row">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-save-row__note">
<mat-label>Note (optional)</mat-label>
@@ -452,101 +954,157 @@
<button mat-stroked-button (click)="closeSaved()">Close</button>
</div>
<div class="rr-summary">
<div class="rr-stat">
<span class="rr-stat__label">NDTL base</span>
<span class="rr-stat__value">{{ amount(savedData.ndtlAmount ?? savedData.dtlAmount) }}</span>
<span class="rr-stat__sub">as at {{ savedData.basis.dtlAsOnDate }}</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">{{ savedData.rateType }} rate</span>
<span class="rr-stat__value">{{ savedData.ratePercentage }}%</span>
<span class="rr-stat__sub">as filed</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Required</span>
<span class="rr-stat__value">{{ amount(savedData.requiredAmount) }}</span>
<span class="rr-stat__sub">{{ savedData.ratePercentage }}% of NDTL</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Maintained</span>
<span class="rr-stat__value">{{ amount(savedData.maintainedAmount) }}</span>
<span class="rr-stat__sub">on {{ savedData.basis.holdingsAsOnDate }}</span>
</div>
<div class="rr-stat" [class.rr-stat--good]="savedData.compliant" [class.rr-stat--bad]="!savedData.compliant">
<span class="rr-stat__label">{{ savedData.compliant ? 'Surplus' : 'Shortfall' }}</span>
<span class="rr-stat__value">{{ amount(savedSurplus) }}</span>
<span class="rr-stat__sub">{{ savedData.compliant ? 'Above requirement' : 'Below requirement' }}</span>
</div>
</div>
<table class="rr-table">
<thead>
<tr>
<th>Particulars</th>
<th>As at</th>
<th class="rr-table__num">Amount (INR)</th>
</tr>
</thead>
<tbody>
@for (line of savedData.lines; track line.configKey) {
<tr class="rr-line" [class.rr-line--unconfigured]="!line.configured">
<td>
<span class="rr-line__label">{{ line.label }}</span>
</td>
<td class="rr-line__date">{{ line.asOnDate }}</td>
<td class="rr-table__num">
@if (line.configured) {
{{ amount(line.amount) }}
} @else {
<span class="rr-dash">—</span>
}
</td>
</tr>
@for (account of line.accounts; track account.glAccountId) {
<tr class="rr-subline">
<td>
<span class="rr-subline__code">{{ account.glCode }}</span>
{{ account.name }}
@if (isPartial(account)) {
<span class="rr-subline__portion">
{{ account.weightPercentage }}% of {{ amount(account.accountBalance) }}
<!--
A statutory snapshot is a different statement from a working report — three dated columns
and no basis block — so it is rendered as the form it is. Through the markup below it read
as a return with every figure blank.
-->
@if (savedIsStatutory && savedStatutory; as saved) {
<div class="f1s-sheet">
<div class="f1s-sheet__head">
<div class="f1s-sheet__title">{{ saved.formName }}</div>
<div class="f1s-sheet__ref">{{ saved.statutoryReference }}</div>
</div>
<table class="f1s-table">
<thead>
<tr>
<th class="f1s-table__particulars">Particulars</th>
<!-- A snapshot saved before the base became a note rather than a column still
carries three columns, so the heading is read from the snapshot itself: its
third column was the base, and saying so keeps an old saved return readable. -->
@for (col of saved.columns; track col; let i = $index) {
<th class="f1s-table__num">
{{ col }}
<span class="f1s-table__colnote">
{{ saved.columns.length > 2 && i === 2 ? 'base for ratios' : 'fortnight close' }}
</span>
}
@if (account.liabilityClass) {
<span class="rr-subline__class">{{ account.liabilityClass | titlecase }}</span>
}
</td>
<td></td>
<td class="rr-table__num">{{ amount(account.balance) }}</td>
@if (saved.baseColumns?.[i]) {
<span class="f1s-table__colbase">charged on {{ saved.baseColumns[i] }}</span>
}
</th>
}
</tr>
}
}
</tbody>
</table>
</thead>
<tbody>
@for (part of savedStatutoryParts; track part.title) {
<tr class="f1s-part">
<td [attr.colspan]="saved.columns.length + 1">{{ part.title }}</td>
</tr>
@for (line of part.lines; track line.label) {
<tr [class.f1s-total]="line.total" [class.f1s-heading]="line.heading">
<td class="f1s-table__particulars" [style.padding-left.px]="12 + (line.indent || 0) * 18">
{{ line.label }}
</td>
@for (value of line.values || []; track $index) {
<td class="f1s-table__num">{{ amount(value) }}</td>
}
</tr>
}
}
</tbody>
</table>
</div>
} @else {
<div class="rr-summary">
<div class="rr-stat">
<span class="rr-stat__label">NDTL base</span>
<span class="rr-stat__value">{{ amount(savedData.ndtlAmount ?? savedData.dtlAmount) }}</span>
<span class="rr-stat__sub">as at {{ savedData.basis.dtlAsOnDate }}</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">{{ savedData.rateType }} rate</span>
<span class="rr-stat__value">{{ savedData.ratePercentage }}%</span>
<span class="rr-stat__sub">as filed</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Required</span>
<span class="rr-stat__value">{{ amount(savedData.requiredAmount) }}</span>
<span class="rr-stat__sub">{{ savedData.ratePercentage }}% of NDTL</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Maintained</span>
<span class="rr-stat__value">{{ amount(savedData.maintainedAmount) }}</span>
<span class="rr-stat__sub">on {{ savedData.basis.holdingsAsOnDate }}</span>
</div>
<div
class="rr-stat"
[class.rr-stat--good]="savedData.compliant"
[class.rr-stat--bad]="!savedData.compliant"
>
<span class="rr-stat__label">{{ savedData.compliant ? 'Surplus' : 'Shortfall' }}</span>
<span class="rr-stat__value">{{ amount(savedSurplus) }}</span>
<span class="rr-stat__sub">{{ savedData.compliant ? 'Above requirement' : 'Below requirement' }}</span>
</div>
</div>
@if (savedData.maintenance; as sm) {
<h4 class="rr-saved__subhead">Daily maintenance as filed — {{ sm.periodLabel || '' }}</h4>
<table class="rr-table">
<thead>
<tr>
<th>Date</th>
<th class="rr-table__num">Required</th>
<th class="rr-table__num">Actual</th>
<th class="rr-table__num">Surplus / (Shortfall)</th>
<th>Particulars</th>
<th>As at</th>
<th class="rr-table__num">Amount (INR)</th>
</tr>
</thead>
<tbody>
@for (day of sm.days; track day.date) {
<tr [class.rr-day--short]="day.amount < sm.dailyMinimumAmount">
<td>{{ day.date }}</td>
<td class="rr-table__num">{{ amount(day.required) }}</td>
<td class="rr-table__num">{{ amount(day.amount) }}</td>
<td class="rr-table__num" [class.rr-neg]="day.surplus < 0">{{ amount(day.surplus) }}</td>
@for (line of savedData.lines; track line.configKey) {
<tr class="rr-line" [class.rr-line--unconfigured]="!line.configured">
<td>
<span class="rr-line__label">{{ line.label }}</span>
</td>
<td class="rr-line__date">{{ line.asOnDate }}</td>
<td class="rr-table__num">
@if (line.configured) {
{{ amount(line.amount) }}
} @else {
<span class="rr-dash">—</span>
}
</td>
</tr>
@for (account of line.accounts; track account.glAccountId) {
<tr class="rr-subline">
<td>
<span class="rr-subline__code">{{ account.glCode }}</span>
{{ account.name }}
@if (isPartial(account)) {
<span class="rr-subline__portion">
{{ account.weightPercentage }}% of {{ amount(account.accountBalance) }}
</span>
}
@if (account.liabilityClass) {
<span class="rr-subline__class">{{ account.liabilityClass | titlecase }}</span>
}
</td>
<td></td>
<td class="rr-table__num">{{ amount(account.balance) }}</td>
</tr>
}
}
</tbody>
</table>
@if (savedData.maintenance; as sm) {
<h4 class="rr-saved__subhead">Daily maintenance as filed — {{ sm.periodLabel || '' }}</h4>
<table class="rr-table">
<thead>
<tr>
<th>Date</th>
<th class="rr-table__num">Required</th>
<th class="rr-table__num">Actual</th>
<th class="rr-table__num">Surplus / (Shortfall)</th>
</tr>
</thead>
<tbody>
@for (day of sm.days; track day.date) {
<tr [class.rr-day--short]="day.amount < sm.dailyMinimumAmount">
<td>{{ day.date }}</td>
<td class="rr-table__num">{{ amount(day.required) }}</td>
<td class="rr-table__num">{{ amount(day.amount) }}</td>
<td class="rr-table__num" [class.rr-neg]="day.surplus < 0">{{ amount(day.surplus) }}</td>
</tr>
}
</tbody>
</table>
}
}
</div>
}
@@ -354,11 +354,23 @@ $red: #c5221f;
color: $gray-900;
}
&__num {
// Declared after the bare `th` and `td` rules above, and qualified with them, so a numeric
// header is not dragged back to the left by `th { text-align: left }` — which matches at the
// same specificity and would otherwise win on source order alone. A heading sitting left of the
// column it heads makes a table of figures read as though the columns were misaligned.
th#{&}__num,
td#{&}__num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
// Figures against their headings, not strung across the full width. Particulars take whatever is
// left, so the money columns stay a constant width and the eye can run down them.
&__num {
width: 1%;
min-width: 116px;
}
}
.rr-line {
@@ -454,6 +466,13 @@ $red: #c5221f;
color: $gray-500;
}
// The cell the return's own figure is taken from. Both dates are printed on every line, so without
// this a reader cannot tell which of the two the requirement was actually computed at.
.rr-cell--used {
background: #f1f6fd;
font-weight: 600;
}
// ── Sections: Memorandum, Annexures, fortnight maintenance ──
.rr-card--section {
+16
View File
@@ -76,6 +76,22 @@ const routes: Routes = [
loadComponent: () =>
import('../interest-report/interest-report.component').then((m) => m.InterestReportComponent)
},
{
path: 'interest-posting-summary',
data: { title: 'Interest Posting Summary', breadcrumb: 'Interest Posting Summary' },
loadComponent: () =>
import('../interest-posting-summary/interest-posting-summary.component').then(
(m) => m.InterestPostingSummaryComponent
)
},
{
path: 'interest-posting',
data: { title: 'Interest Posting Reports', breadcrumb: 'Interest Posting Reports' },
loadComponent: () =>
import('../interest-posting-report/interest-posting-report.component').then(
(m) => m.InterestPostingReportComponent
)
},
{
path: 'dormancy',
data: { title: 'Inactive, Dormant & DEAF', breadcrumb: 'Inactive, Dormant & DEAF' },
+8
View File
@@ -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<any>}
@@ -130,103 +130,4 @@
<p class="empty-text">There are no accounts awaiting approval at this time.</p>
</div>
}
<!-- Share accounts awaiting approval render here too, rather than on their own tab. Own table,
own per-row approve/reject/view actions - unchanged from their previous standalone screen. -->
<h3 class="pa-section-heading">Pending Share Accounts</h3>
@if (showShareData) {
<div class="psa-action-bar">
<mat-form-field appearance="outline" class="psa-search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#shareFilterInput
matInput
placeholder="Search by account number, member or product"
(keyup)="applyShareFilter(shareFilterInput.value)"
/>
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="shareDataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let account" class="psa-mono">{{ account.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Member</th>
<td mat-cell *matCellDef="let account">
{{ account.clientName }}
@if (account.officeName) {
<div class="psa-sub">{{ account.officeName }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let account">{{ account.productName }}</td>
</ng-container>
<ng-container matColumnDef="requestedShares">
<th mat-header-cell *matHeaderCellDef class="ta-right">Shares</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">{{ account.requestedShares }}</td>
</ng-container>
<ng-container matColumnDef="requestedAmount">
<th mat-header-cell *matHeaderCellDef class="ta-right">Amount</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">
{{ account.requestedAmount | number: '1.2-2' }}
</td>
</ng-container>
<ng-container matColumnDef="resolution">
<th mat-header-cell *matHeaderCellDef>Resolution</th>
<td mat-cell *matCellDef="let account">
{{ account.resolutionNumber || '—' }}
@if (account.resolutionDate) {
<div class="psa-sub">{{ formatShareDate(account.resolutionDate) }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="submittedDate">
<th mat-header-cell *matHeaderCellDef>Submitted</th>
<td mat-cell *matCellDef="let account">
{{ formatShareDate(account.submittedDate) }}
@if (account.submittedByUsername) {
<div class="psa-sub">by {{ account.submittedByUsername }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let account">
<button mat-stroked-button class="psa-btn psa-btn-view" (click)="viewShareAccount(account)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
</button>
<button mat-stroked-button class="psa-btn psa-btn-approve" (click)="approveShareAccount(account)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psa-btn psa-btn-reject" (click)="rejectShareAccount(account)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="shareDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: shareDisplayedColumns" class="table-row"></tr>
</table>
</div>
}
@if (!showShareData) {
<div class="empty-state">
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
<p class="empty-title">No pending share accounts</p>
<p class="empty-text">There are no share accounts awaiting approval at this time.</p>
</div>
}
</div>
@@ -14,16 +14,6 @@ $text-muted: #64748b;
padding: 16px 0;
}
/* Marks where the Share Account section begins below the normal-account table on this same screen. */
.pa-section-heading {
margin: 28px 0 12px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
padding-top: 16px;
border-top: 1px solid $border;
}
.cv-action-bar {
display: flex;
align-items: center;
@@ -29,24 +29,17 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { TasksService } from '../../tasks.service';
import { SharesService } from 'app/shares/shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { DecimalPipe } from '@angular/common';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicker-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-pending-account',
templateUrl: './pending-account.component.html',
styleUrls: [
'./pending-account.component.scss',
'../pending-share-accounts/pending-share-accounts.component.scss'
],
styleUrls: ['./pending-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
@@ -61,8 +54,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
DecimalPipe
MatIcon
]
})
export class PendingAccountComponent {
@@ -72,7 +64,6 @@ export class PendingAccountComponent {
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private tasksService = inject(TasksService);
private sharesService = inject(SharesService);
dataSource: MatTableDataSource<any>;
selection: SelectionModel<any>;
@@ -90,26 +81,8 @@ export class PendingAccountComponent {
'status'
];
/** Share accounts awaiting approval - rendered as their own section on this same screen rather
* than a separate tab. Kept out of `dataSource`/`selection` (bulk-select + date-picker approve)
* since a share subscription is approved/rejected one at a time against today's business date,
* exactly as it always was on its own page - only where it's shown has changed. */
shareDataSource: MatTableDataSource<any>;
showShareData = false;
shareDisplayedColumns: string[] = [
'accountNo',
'clientName',
'productName',
'requestedShares',
'requestedAmount',
'resolution',
'submittedDate',
'actions'
];
constructor() {
this.route.data.subscribe((data: { pendingAccountsData: any; pendingShareAccountsData: any }) => {
this.route.data.subscribe((data: { pendingAccountsData: any }) => {
// status 100 = submitted and pending approval only; exclude terminated accounts
const items = (data.pendingAccountsData?.pageItems || []).filter(
(a: any) => a.status?.id === 100 && !a.terminateTrue
@@ -117,10 +90,6 @@ export class PendingAccountComponent {
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
this.selection = new SelectionModel(true, []);
const shareItems = data.pendingShareAccountsData || [];
this.showShareData = shareItems.length > 0;
this.shareDataSource = new MatTableDataSource(shareItems);
});
}
@@ -314,92 +283,6 @@ export class PendingAccountComponent {
return date;
}
applyShareFilter(filterValue: string = '') {
this.shareDataSource.filter = filterValue.trim().toLowerCase();
}
/** A date from the share account API rendered readably, whether it arrives as a string or as
* Fineract's {@code [yyyy, m, d]} array. Kept separate from `formatDate()` above, which the
* normal-account table already relies on with its own date-array handling. */
formatShareDate(date: any): string {
if (!date) {
return '';
}
const d = Array.isArray(date) ? new Date(date[0], date[1] - 1, date[2]) : new Date(date);
return isNaN(d.getTime()) ? String(date) : d.toLocaleDateString('en-IN');
}
/** Opens the share account itself, for a checker who wants more than the row shows. */
viewShareAccount(account: any): void {
this.router.navigate([
'/clients',
account.clientId,
'shares-accounts',
account.shareAccountId
]);
}
approveShareAccount(account: any): void {
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Approve Share Account',
dialogContext:
`Approve share account "${account.accountNo}" for ${account.clientName} — ` +
`${account.requestedShares} shares at ${account.requestedAmount}?`,
type: 'basic'
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
// The approval date is the business date rather than the checker's choice, same as this
// queue's original standalone screen: clearing a submission is a decision, not a backdated
// transaction, so a date field per row would be extra clicks for no benefit.
const data = {
approvedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.sharesService
.executeSharesAccountCommand(account.shareAccountId, 'approve', data)
.subscribe(() => this.reload());
}
});
}
rejectShareAccount(account: any): void {
const formfields: FormfieldBase[] = [
new InputBase({
controlName: 'rejectReason',
label: 'Reason for rejection',
value: '',
type: 'text',
required: false
})
];
const dialogRef = this.dialog.open(FormDialogComponent, {
data: {
title: `Reject Share Account ${account.accountNo}`,
formfields
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.data) {
const data: any = {
rejectedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
const reason = response.data.value?.rejectReason?.trim();
if (reason) {
data.note = reason;
}
this.sharesService
.executeSharesAccountCommand(account.shareAccountId, 'reject', data)
.subscribe(() => this.reload());
}
});
}
reload() {
const url = this.router.url;
this.router
@@ -52,16 +52,29 @@ export class ProductEditDiffDialogComponent implements OnInit {
productName = '';
rows: DiffRow[] = [];
/** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews. */
/** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews.
* otherGlMappings is here too -- it is a nested {assets,liabilities,expenses,income} document, not
* a scalar, so it is handled separately by namedGlMappingRows() rather than the generic loop below
* (which would otherwise dump it as a raw, unreadable JSON blob). */
private static readonly IGNORED_KEYS = new Set([
'locale',
'dateFormat',
'monthDayFormat',
'id',
'productId',
'savingsProductId'
'savingsProductId',
'otherGlMappings'
]);
/** The two reserved otherGlMappings entries a checker actually needs to see by name -- see
* AccountingProcessorHelper.SAVINGS_INTEREST_PAYABLE_MAPPING / SAVINGS_INTEREST_PAID_MAPPING. Any
* other entry in the assets/liabilities/expenses/income lists is a free-form "Other" row with no
* fixed identity to diff against by name, so those are left out of this dialog same as before. */
private static readonly NAMED_GL_MAPPINGS = [
'Savings Interest Payable',
'Savings Interest Paid'
];
/**
* Payload fields the stored product exposes under a different path, so the "current" side has
* something to compare against instead of always reading as newly set.
@@ -115,6 +128,9 @@ export class ProductEditDiffDialogComponent implements OnInit {
};
private glAccountNamesById = new Map<string, string>();
/** Same pool as glAccountNamesById, keyed by GL code instead of id -- otherGlMappings entries (see
* namedGlMappingRows) identify an account by its code, not its id. */
private glAccountNamesByCode = new Map<string, string>();
ngOnInit(): void {
this.productName = this.data.productName ?? '';
@@ -131,10 +147,11 @@ export class ProductEditDiffDialogComponent implements OnInit {
original: this.displayValue(originalValue, key, original),
proposed: this.displayValue(proposed[key], key, original)
}))
.concat(this.namedGlMappingRows(original, proposed))
.sort((a, b) => a.label.localeCompare(b.label));
}
/** Pools every GL account the template offers so an account id can be shown as its name. */
/** Pools every GL account the template offers so an account id/code can be shown as its name. */
private buildGlAccountLookup(template: any): void {
const optionGroups = template?.accountingMappingOptions ?? {};
for (const group of Object.values<any>(optionGroups)) {
@@ -146,13 +163,68 @@ export class ProductEditDiffDialogComponent implements OnInit {
continue;
}
const label = account.name ?? account.glCode ?? null;
if (label) {
this.glAccountNamesById.set(String(account.id), account.glCode ? `${label} (${account.glCode})` : label);
if (!label) {
continue;
}
const display = account.glCode ? `${label} (${account.glCode})` : label;
this.glAccountNamesById.set(String(account.id), display);
if (account.glCode) {
this.glAccountNamesByCode.set(String(account.glCode), display);
}
}
}
}
/**
* Diffs the two reserved otherGlMappings entries by name across every category (assets/liabilities/
* expenses/income) rather than the generic key loop above, since otherGlMappings is a nested
* {assets,liabilities,expenses,income} document whose entries move category if the maker changes
* which account type they picked -- comparing category-by-category would miss that. `original`
* carries this document as otherGlMappingsMap (falling back to the older otherGlMappings key for a
* template response that hasn't been re-fetched since), `proposed` as otherGlMappings.
*/
private namedGlMappingRows(original: any, proposed: any): DiffRow[] {
const proposedOther = proposed?.otherGlMappings;
if (proposedOther === undefined) {
// Not part of this edit at all -- nothing to compare, same as any other field the maker didn't touch.
return [];
}
const originalOther = original?.otherGlMappingsMap ?? original?.otherGlMappings;
const findByName = (doc: any, accountName: string): { glCode?: string } | null => {
const categories: any[] = doc ? Object.values(doc) : [];
for (const category of categories) {
if (!Array.isArray(category)) {
continue;
}
const match = category.find((entry: any) => entry?.accountName === accountName);
if (match) {
return match;
}
}
return null;
};
const resolve = (entry: { glCode?: string } | null): string => {
if (!entry?.glCode) {
return '—';
}
return this.glAccountNamesByCode.get(String(entry.glCode)) ?? entry.glCode;
};
return ProductEditDiffDialogComponent.NAMED_GL_MAPPINGS.map((accountName) => {
const before = findByName(originalOther, accountName);
const after = findByName(proposedOther, accountName);
return { accountName, before, after };
})
.filter(({ before, after }) => (before?.glCode ?? null) !== (after?.glCode ?? null))
.map(({ accountName, before, after }) => ({
label: accountName,
original: resolve(before),
proposed: resolve(after)
}));
}
/** Reads the current value for a payload field, following an alias or GL mapping when needed. */
private originalValueFor(key: string, original: any): any {
const glMappingKey = ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[key];
@@ -72,6 +72,15 @@
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span>
</a>
<a
class="ac-tab"
routerLink="./pending-share-accounts"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Share Accounts</span>
</a>
<a
class="ac-tab"
routerLink="./product-approval"
+1 -4
View File
@@ -108,10 +108,7 @@ const routes: Routes = [
component: PendingAccountComponent,
data: { title: 'Pending Account' },
resolve: {
pendingAccountsData: GetPendingAccounts,
// Share accounts awaiting approval now render inside this same screen instead of their
// own tab - reuses the same resolver the standalone route below still uses.
pendingShareAccountsData: GetPendingShareAccounts
pendingAccountsData: GetPendingAccounts
}
},
{
@@ -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);
});
});
+379
View File
@@ -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<TdsAccountLine['type'], string> = {
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<ExcelJS.Borders> {
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));
}
@@ -13,15 +13,21 @@
<div>
<h2 class="tdsr__title">Deposit Interest &amp; TDS</h2>
<div class="tdsr__sub">
Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} ·
{{ scopeLabel }}
Interest each customer earned this year, and whether TDS has to be deducted on it ·
<strong>Showing: {{ scopeLabel }}</strong>
</div>
</div>
</div>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
<div class="tdsr__head-r">
<button type="button" class="tdsr__btn" (click)="showHelp = !showHelp">
<fa-icon icon="question-circle"></fa-icon>
{{ showHelp ? 'Hide help' : 'How to read this page' }}
</button>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
</div>
@if (loadError) {
@@ -31,57 +37,104 @@
</div>
}
<!-- Help -->
@if (showHelp) {
<div class="tdsr__help">
<div class="tdsr__help-title"><fa-icon icon="info-circle"></fa-icon> How to read this page</div>
<ol class="tdsr__help-list">
<li>
Every customer with an account is listed. Each account is either
<span class="tdsr__pill tdsr__pill--ok">Tracked</span> — its interest counts toward the TDS limit — or
<span class="tdsr__pill">Not tracked</span>. Open a row (<fa-icon icon="chevron-down"></fa-icon>) to see which
account is which, and why.
</li>
<li>
Only the interest from <strong>fixed and recurring deposits that reaches the customer's savings account</strong>
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.
</li>
@if (rules) {
<li>
A customer's tracked interest for the whole year, across every branch, is checked against the limit:
<strong>₹{{ rules.general | number: '1.0-0' }}</strong
>, or <strong>₹{{ rules.senior | number: '1.0-0' }}</strong> for a senior citizen (60+).
</li>
<li>
Once over the limit, TDS is <strong>{{ rules.rate | number: '1.0-2' }}%</strong> of the
<em>whole</em> year's interest — <strong>{{ rules.rateNoPan | number: '1.0-2' }}%</strong> if no PAN is on
record.
</li>
}
<li>
A customer who has submitted Form 15G/15H is not deducted on interest received after the form was accepted.
</li>
<li>
TDS is <strong>debited from the savings account</strong> 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 <strong>TDS</strong> column shows what is due by these rules and what was actually deducted — the two
should match.
</li>
</ol>
</div>
}
<!-- Summary -->
<div class="tdsr__kpis">
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees</div>
<div class="tdsr__kpi-l">Customers · {{ summary.trackedCustomers | number }} with an FD/RD being tracked</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.applicableInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on FD / RD</div>
<div class="tdsr__kpi-l">FD / RD interest received in savings — counts toward the limit</div>
</div>
<div class="tdsr__kpi tdsr__kpi--muted">
<div class="tdsr__kpi-n">₹{{ summary.exemptInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Savings interest — outside the charge</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS payable</div>
<div class="tdsr__kpi-l">Savings interest — shown only, no TDS</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.crossed | number }}</div>
<div class="tdsr__kpi-l">Crossed the threshold</div>
<div class="tdsr__kpi-l">Customers over the limit</div>
</div>
<button type="button" class="tdsr__kpi tdsr__kpi--action" (click)="showNearThreshold()">
<div class="tdsr__kpi-n">{{ summary.nearThreshold | number }}</div>
<div class="tdsr__kpi-l">Within ₹{{ nearBand | number: '1.0-0' }} of it — chase a declaration</div>
</button>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.tdsSaved | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Saved by {{ summary.declarations | number }} declarations</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS due by the rules</div>
</div>
@if (summary.noPan > 0) {
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.noPan | number }}</div>
<div class="tdsr__kpi-l">No PAN — deducted at 20%</div>
<div class="tdsr__kpi" [class.tdsr__kpi--ok]="summary.actualTds === summary.totalTds"
[class.tdsr__kpi--warn]="summary.actualTds !== summary.totalTds">
<div class="tdsr__kpi-n">₹{{ summary.actualTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">
Actually deducted from accounts
@if (summary.actualTds !== summary.totalTds) {
— ₹{{ summary.totalTds - summary.actualTds | number: '1.0-0' }} apart
}
</div>
}
</div>
</div>
<!-- Quarter strip -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="calendar"></fa-icon> Quarter by quarter</div>
<div class="tdsr__qstrip">
@for (q of summary.byQuarter; track q.quarter) {
<div class="tdsr__qcell">
<div class="tdsr__qcell-l">{{ quarterLabel[q.quarter] }}</div>
<div class="tdsr__qcell-n">₹{{ q.interest | number: '1.0-0' }}</div>
<div class="tdsr__qcell-s">TDS ₹{{ q.tds | number: '1.0-0' }}</div>
</div>
<!-- Things worth acting on, only when there are any -->
@if (summary.nearThreshold || summary.declarations || summary.noPan) {
<div class="tdsr__chips">
@if (summary.nearThreshold) {
<button type="button" class="tdsr__chip tdsr__chip--near" (click)="showNearThreshold()">
{{ summary.nearThreshold | number }} near the limit (within ₹{{ nearBand | number: '1.0-0' }}) — ask for
15G/15H
</button>
}
@if (summary.declarations) {
<span class="tdsr__chip tdsr__chip--ok">
{{ summary.declarations | number }} with 15G/15H — ₹{{ summary.tdsSaved | number: '1.0-0' }} not deducted
</span>
}
@if (summary.noPan) {
<span class="tdsr__chip tdsr__chip--warn">
{{ summary.noPan | number }} FD/RD customers without PAN — higher rate applies
</span>
}
</div>
</div>
}
<!-- Filters -->
<div class="tdsr__filters">
@@ -99,8 +152,8 @@
<span>Branch</span>
<select [(ngModel)]="filter.branchCode" (ngModelChange)="onFilterChanged()">
<option value="">All branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</label>
@@ -117,32 +170,32 @@
</label>
<label class="tdsr__filter">
<span>Threshold band</span>
<span>Age group</span>
<select [(ngModel)]="filter.band" (ngModelChange)="onFilterChanged()">
<option value="">Both bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="">Everyone</option>
<option value="GENERAL">Below 60</option>
<option value="SENIOR">Senior citizen (60+)</option>
</select>
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Customer / CIF / PAN</span>
<span>Search customer</span>
<input
type="text"
[(ngModel)]="filter.search"
(ngModelChange)="onFilterChanged()"
placeholder="name, CIF or PAN"
placeholder="Name, customer ID or PAN"
/>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.crossedOnly" (ngModelChange)="onFilterChanged()" />
<span>Threshold crossed only</span>
<span>Over the limit only</span>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" (ngModelChange)="onFilterChanged()" />
<span>Near threshold only</span>
<span>Near the limit only</span>
</label>
@if (hasFilters) {
@@ -150,7 +203,7 @@
}
</div>
<!-- Tabs Selector -->
<!-- Tabs -->
<div class="tdsr__tabs">
<button
type="button"
@@ -158,7 +211,15 @@
[class.tdsr__tab--active]="selectedTab === 'paying'"
(click)="selectTab('paying')"
>
Paying TDS ({{ payingRows.length }})
TDS deducted / due ({{ payingRows.length }})
</button>
<button
type="button"
class="tdsr__tab"
[class.tdsr__tab--active]="selectedTab === 'declared'"
(click)="selectTab('declared')"
>
Form 121 / 15G / 15H given ({{ declaredRows.length }})
</button>
<button
type="button"
@@ -166,97 +227,128 @@
[class.tdsr__tab--active]="selectedTab === 'not-paying'"
(click)="selectTab('not-paying')"
>
Not Paying TDS ({{ notPayingRows.length }})
No TDS ({{ notPayingRows.length }})
</button>
</div>
@if (selectedTab === 'declared') {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
Customers who gave a declaration for FY {{ filter.fy }}. <strong>TDS not deducted</strong> is the tax that would
have been taken without the form; <strong>Paid without TDS</strong> is the interest received after the form was
accepted — it must still be reported in the {{ statute.statement }} return. Interest received before the form
stays taxable, and any TDS taken on it is shown as <strong>deducted before the form</strong>.
</div>
</div>
}
<!-- Table -->
<div class="tdsr__card">
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Customer</th>
<th>Branch</th>
<th>Form · Band</th>
<th class="num">Q1</th>
<th class="num">Q2</th>
<th class="num">Q3</th>
<th class="num">Q4</th>
<th class="num">Year total</th>
<th class="num">Threshold</th>
<th>Crossed</th>
<th class="num">TDS</th>
<th class="num">Saved</th>
@if (selectedTab === 'declared') {
<th>Form · UIN · accepted on</th>
<th class="num">FD / RD interest received · paid without TDS</th>
<th class="num">Limit</th>
<th>Status</th>
<th class="num">TDS not deducted · deducted before the form</th>
} @else {
<th>Accounts</th>
<th class="num">FD / RD interest received this year</th>
<th class="num">Limit</th>
<th>Status</th>
<th class="num">TDS</th>
}
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (row of pagedRows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
<tr class="tdsr__row--click" [class.tdsr__row--near]="row.nearThreshold" (click)="toggleExpanded(row)">
<td>
<div class="tdsr__name">{{ row.deductee.name }}</div>
<div class="mono small muted">
{{ row.deductee.customerId }} ·
ID {{ row.deductee.customerId }} ·
@if (row.panOnRecord) {
{{ row.deductee.pan }}
PAN {{ row.deductee.pan }}
} @else {
<span class="tdsr__pill tdsr__pill--warn">no PAN</span>
}
@if (row.band === 'SENIOR') {
<span class="tdsr__pill">Senior 60+</span>
}
</div>
</td>
<td class="small">{{ branchName(row.branchCode) }}</td>
<td>
<span class="tdsr__pill" [class.tdsr__pill--ok]="row.hasDeclaration">
{{ row.form === '121' ? '121' : row.form }}
</span>
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior' : 'General' }}</div>
@if (row.hasDeclaration) {
<div class="mono small muted">{{ row.declarationUin }}</div>
}
</td>
@for (q of quarters; track q) {
<td class="num small">
@if (row.quarters[q]) {
₹{{ row.quarters[q] | number: '1.0-0' }}
} @else {
<span class="muted">—</span>
<td class="small">{{ row.branchCode || '—' }}</td>
@if (selectedTab === 'declared') {
<td class="small">
<span class="tdsr__pill tdsr__pill--ok">Form {{ row.form }}</span>
<div class="mono muted">{{ row.declarationUin || 'no UIN yet' }}</div>
<div class="muted">accepted {{ row.declarationAcceptedOn | date: 'd MMM y' }}</div>
</td>
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
<div class="small tdsr__ok">₹{{ row.paidWithoutDeduction | number: '1.0-0' }} paid without TDS</div>
</td>
} @else {
<td class="small">
@if (row.trackedAccounts) {
<span class="tdsr__pill tdsr__pill--ok">{{ row.trackedAccounts }} tracked</span>
}
@if (untrackedOf(row).length) {
<span class="tdsr__pill">{{ untrackedOf(row).length }} not tracked</span>
}
</td>
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
@if (row.exemptInterest) {
<div class="small muted">+ ₹{{ row.exemptInterest | number: '1.0-0' }} savings (no TDS)</div>
}
</td>
}
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
@if (row.exemptInterest) {
<div class="small muted">+₹{{ row.exemptInterest | number: '1.0-0' }} SB (exempt)</div>
}
</td>
<td class="num small muted">₹{{ row.threshold | number: '1.0-0' }}</td>
<td class="small">
@if (row.crossedInQuarter) {
<span class="tdsr__pill tdsr__pill--warn">Q{{ row.crossedInQuarter }}</span>
} @else if (row.nearThreshold) {
<span class="tdsr__pill tdsr__pill--near"> ₹{{ row.headroom | number: '1.0-0' }} left </span>
} @else {
<span class="muted">—</span>
}
<span
class="tdsr__pill"
[class.tdsr__pill--warn]="statusOf(row).tone === 'warn'"
[class.tdsr__pill--near]="statusOf(row).tone === 'near'"
[class.tdsr__pill--ok]="statusOf(row).tone === 'ok'"
>
{{ statusOf(row).label }}
</span>
</td>
<td class="num">
@if (row.schedule.totalTds) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">@ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="num">
@if (row.tdsSaved) {
@if (selectedTab === 'declared') {
<strong class="tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
<div class="small muted">not deducted</div>
@if (row.actualTds || row.schedule.totalTds) {
<div class="small">₹{{ row.actualTds | number: '1.0-0' }} deducted before the form</div>
}
} @else if (row.schedule.totalTds || row.actualTds) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">due @ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
<div class="small" [class.tdsr__ok]="row.actualTds === row.schedule.totalTds"
[class.tdsr__warn-text]="row.actualTds !== row.schedule.totalTds">
₹{{ row.actualTds | number: '1.0-0' }} deducted
</div>
} @else if (row.tdsSaved) {
<span class="small tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }} saved</span>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
<button type="button" class="tdsr__icon-btn" (click)="toggleExpanded(row)">
<button
type="button"
class="tdsr__icon-btn"
[attr.aria-label]="isExpanded(row) ? 'Hide details' : 'Show details'"
(click)="$event.stopPropagation(); toggleExpanded(row)"
>
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
</button>
</td>
@@ -264,89 +356,154 @@
@if (isExpanded(row)) {
<tr class="tdsr__row--detail">
<td colspan="13">
@for (note of row.schedule.notes; track note) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@if (row.jointHeldInterest) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest sits on deposits where this customer
is a joint holder but not the first-named one. Tax there is deducted against the first-named
holder, so it is excluded from the figures above.
</div>
</div>
}
<td colspan="8">
<!-- 1. Which accounts are tracked -->
<div class="tdsr__detail-h">Accounts of {{ row.deductee.name }}</div>
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
<th>Account no.</th>
<th>Type</th>
<th>Tracked for TDS?</th>
<th class="num">Interest this year (FD/RD: received in savings)</th>
<th class="num">Credits</th>
<th>Last credited</th>
<th class="num">TDS deducted</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="tdsr__pill tdsr__pill--warn">crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">Form 121 active</span>
@for (a of row.accounts; track a.accountNo) {
<tr>
<td class="mono small">
{{ a.accountNo }}
@if (a.holderName) {
<div class="muted">held by {{ a.holderName }}</div>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="small">{{ accountTypeLabel[a.type] }}</td>
<td class="small">
@if (a.tracked) {
<span class="tdsr__pill tdsr__pill--ok"><fa-icon icon="check"></fa-icon> Yes</span>
} @else {
<span class="tdsr__pill">No</span>
}
<span class="muted"> {{ a.reason }}</span>
</td>
<td class="num">₹{{ a.interest | number: '1.2-2' }}</td>
<td class="num small">{{ a.postings }}</td>
<td class="small muted">
{{ a.lastPostedOn ? (a.lastPostedOn | date: 'd MMM y') : 'Nothing received yet' }}
</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
@if (a.tdsDeducted) {
₹{{ a.tdsDeducted | number: '1.2-2' }}
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
</tbody>
</table>
<div class="tdsr__meta">
<span
>Settlement: <strong>{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</strong> (set by
the deposit product)</span
>
@if (row.declarationAcceptedOn) {
<span
>Declaration accepted: <strong>{{ row.declarationAcceptedOn | date: 'd MMM y' }}</strong></span
>
<!-- 2. How the TDS was worked out -->
@if (row.trackedAccounts) {
<div class="tdsr__detail-h">How the TDS is worked out, quarter by quarter</div>
@for (note of row.schedule.notes; track note) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@if (row.schedule.lines.length) {
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest in quarter</th>
<th class="num">Total so far</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="tdsr__pill tdsr__pill--warn">limit crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">15G/15H active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">
₹{{ line.cumInterest | number: '1.0-0' }}
<span class="small muted">of ₹{{ row.threshold | number: '1.0-0' }}</span>
</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="small muted">No interest has been posted on the tracked deposits this year yet.</div>
}
@if (row.paidWithoutDeduction) {
<span>
Paid without deduction:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — reportable in
{{ statute.statement }}
</span>
<div class="tdsr__meta">
<span>
Paid without deduction because of 15G/15H:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — still reported in
{{ statute.statement }}
</span>
</div>
}
</div>
} @else {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
This customer has no fixed or recurring deposit, so nothing here counts toward TDS. Savings
interest is shown for information only.
</div>
</div>
}
@if (row.jointHeldInterest) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest is on joint deposits where this
customer is not the first-named holder, so it is counted under that holder instead.
</div>
</div>
}
</td>
</tr>
}
}
@if (visibleRows.length === 0) {
<tr>
<td colspan="13" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No customers in this section match the filters.
<td colspan="8" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon>
@if (loading) {
Loading…
} @else if (selectedTab === 'paying') {
No customer needs TDS deducted for these filters. Check the "No TDS" tab.
} @else if (selectedTab === 'declared') {
No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's
Form 121 tab.
} @else {
No customers match these filters.
}
</td>
</tr>
}
@@ -359,8 +516,7 @@
<div class="tdsr__pager-l">
<span>
Showing <strong>{{ rangeStart | number }}–{{ rangeEnd | number }}</strong> of
<strong>{{ visibleRows.length | number }}</strong>
{{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }}
<strong>{{ visibleRows.length | number }}</strong> customers
</span>
<label class="tdsr__pager-sizel">
Rows
@@ -10,15 +10,15 @@ import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { setTdsDeductees, tdsFys } from './tds-reports-data';
import { TdsDeductee, setTdsDeductees, tdsFys } from './tds-reports-data';
import { buildTdsInterestWorkbook } from './tds-interest-export';
import { TdsReportsService } from './tds-reports.service';
import {
NEAR_THRESHOLD_BAND,
TdsAccountLine,
TdsInterestRow,
TdsInterestSummary,
TdsReportFilter,
@@ -26,6 +26,9 @@ import {
tdsInterestSummary
} from './tds-reports.compute';
/** The three answers the register gives: TDS taken, not taken because of a declaration, none due. */
type TdsTab = 'paying' | 'declared' | 'not-paying';
@Component({
selector: 'mifosx-tds-interest-report',
templateUrl: './tds-interest-report.component.html',
@@ -40,7 +43,12 @@ export class TdsInterestReportComponent implements OnInit {
private scope = inject(BranchScopeService);
private tdsService = inject(TdsReportsService);
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
/**
* Office names the loaded customers belong to, for Head Office's branch selector. Read off the
* data rather than a fixed list, which named branches this bank does not have and left out the
* ones it does.
*/
branches: string[] = [];
/** Filled by the route resolver from Organization › TDS Configuration. */
readonly financialYears = tdsFys();
readonly quarterLabel = QUARTER_LABEL;
@@ -65,7 +73,7 @@ export class TdsInterestReportComponent implements OnInit {
/** Which customer's quarter-wise detail is open. */
expandedId: string | null = null;
exporting = false;
selectedTab: 'paying' | 'not-paying' = 'paying';
selectedTab: TdsTab = 'paying';
/** Rows per page; 0 shows the whole list. */
readonly pageSizeOptions = [
@@ -87,21 +95,32 @@ export class TdsInterestReportComponent implements OnInit {
private allRows: TdsInterestRow[] = [];
private paying: TdsInterestRow[] = [];
private notPaying: TdsInterestRow[] = [];
private declared: TdsInterestRow[] = [];
private summaryCacheKey = '';
private cachedSummary: TdsInterestSummary | null = null;
loading = false;
loadError = '';
/** The "how to read this page" panel. */
showHelp = true;
readonly accountTypeLabel: Record<TdsAccountLine['type'], string> = {
FD: 'Fixed deposit',
RD: 'Recurring deposit',
SB: 'Savings',
OTHER: 'Other'
};
ngOnInit(): void {
if (!this.financialYears.length) {
this.loadError = 'No financial year is configured for TDS. Set one up under Organization › TDS Configuration.';
return;
}
// A branch login sees only its own book and cannot widen the scope.
const own = this.scope.ownBranchCode;
if (own) {
this.filter.branchCode = own;
// A branch login sees only its own book and cannot widen the scope. The platform labels each
// customer with their office's name, so the login's office name is the filter — the GL branch
// code list does not know every office and turned a branch login into an empty report.
if (!this.scope.isHeadOffice) {
this.filter.branchCode = this.scope.officeName;
}
this.loadDeductees();
}
@@ -121,6 +140,7 @@ export class TdsInterestReportComponent implements OnInit {
next: (deductees) => {
this.loading = false;
setTdsDeductees(deductees);
this.branches = this.branchesOf(deductees);
this.invalidateCaches();
},
error: (err: any) => {
@@ -138,6 +158,11 @@ export class TdsInterestReportComponent implements OnInit {
* only the summary left the KPIs counting the new data while the tables kept the empty list
* cached on the first render, before the load returned.
*/
private branchesOf(deductees: TdsDeductee[]): string[] {
const names = new Set(deductees.map((d) => d.branchCode).filter((n) => !!n));
return [...names].sort((a, b) => a.localeCompare(b));
}
private invalidateCaches(): void {
this.rowsCacheKey = '';
this.summaryCacheKey = '';
@@ -168,12 +193,74 @@ export class TdsInterestReportComponent implements OnInit {
};
}
/** The limits and rates for the selected year, for the help panel. */
get rules(): { general: number; senior: number; rate: number; rateNoPan: number } | null {
const cfg = FY_TAX_CONFIG[this.filter.fy];
if (!cfg) {
return null;
}
return {
general: cfg.thresholdGeneral,
senior: cfg.thresholdSenior,
rate: cfg.rate * 100,
rateNoPan: cfg.rateNoPan * 100
};
}
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get scopeLabel(): string {
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
return this.filter.branchCode ? `${this.filter.branchCode} branch` : 'All branches';
}
/**
* One plain-language status per customer, so a reader does not have to work it out from the
* threshold, crossed and saved columns together.
*/
statusOf(row: TdsInterestRow): { label: string; tone: 'muted' | 'warn' | 'near' | 'ok' } {
if (!row.trackedAccounts) {
return { label: 'Not tracked — no FD/RD', tone: 'muted' };
}
if (row.hasDeclaration) {
const beforeForm = Math.round(row.schedule.totalTds - row.actualTds);
if (beforeForm > 0) {
return { label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`, tone: 'warn' };
}
if (row.tdsSaved > 0) {
return { label: `Form ${row.form} — TDS not deducted`, tone: 'ok' };
}
return { label: `Form ${row.form} on record — below the limit so far`, tone: 'ok' };
}
if (row.schedule.totalTds > 0) {
const gap = Math.round(row.schedule.totalTds - row.actualTds);
if (gap > 0) {
return { label: `Over the limit — ₹${gap.toLocaleString('en-IN')} not yet deducted`, tone: 'warn' };
}
if (gap < 0) {
return { label: `Deducted ₹${(-gap).toLocaleString('en-IN')} more than due — check`, tone: 'warn' };
}
return { label: 'Over the limit — TDS deducted', tone: 'ok' };
}
if (row.actualTds > 0) {
return { label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`, tone: 'warn' };
}
if (row.hasDeclaration && row.crossed) {
return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' };
}
if (row.nearThreshold) {
return { label: `Near the limit — ₹${Math.round(row.headroom).toLocaleString('en-IN')} left`, tone: 'near' };
}
return { label: 'Below the limit', tone: 'ok' };
}
trackedOf(row: TdsInterestRow): TdsAccountLine[] {
return row.accounts.filter((a) => a.tracked);
}
untrackedOf(row: TdsInterestRow): TdsAccountLine[] {
return row.accounts.filter((a) => !a.tracked);
}
/** Recomputes the book only when the filter actually changed. Side-effect free. */
@@ -184,8 +271,12 @@ export class TdsInterestReportComponent implements OnInit {
}
this.rowsCacheKey = key;
this.allRows = tdsInterestRows(this.filter);
this.paying = this.allRows.filter((r) => r.schedule.totalTds > 0);
this.notPaying = this.allRows.filter((r) => r.schedule.totalTds === 0);
// Three plain answers: whose TDS was taken, whose was not because they gave a declaration, and who
// owed none. A declarant whose tax was partly taken before the form is shown with the declarants,
// where both figures sit side by side.
this.declared = this.allRows.filter((r) => r.hasDeclaration);
this.paying = this.allRows.filter((r) => !r.hasDeclaration && (r.schedule.totalTds > 0 || r.actualTds > 0));
this.notPaying = this.allRows.filter((r) => !r.hasDeclaration && r.schedule.totalTds === 0 && r.actualTds === 0);
}
get rows(): TdsInterestRow[] {
@@ -203,6 +294,11 @@ export class TdsInterestReportComponent implements OnInit {
return this.notPaying;
}
get declaredRows(): TdsInterestRow[] {
this.ensureRows();
return this.declared;
}
get summary(): TdsInterestSummary {
const key = JSON.stringify(this.filter);
if (key !== this.summaryCacheKey || !this.cachedSummary) {
@@ -214,6 +310,9 @@ export class TdsInterestReportComponent implements OnInit {
/** Every row of the tab on screen, before paging. */
get visibleRows(): TdsInterestRow[] {
if (this.selectedTab === 'declared') {
return this.declaredRows;
}
return this.selectedTab === 'paying' ? this.payingRows : this.notPayingRows;
}
@@ -274,7 +373,7 @@ export class TdsInterestReportComponent implements OnInit {
this.expandedId = null;
}
selectTab(tab: 'paying' | 'not-paying'): void {
selectTab(tab: TdsTab): void {
this.selectedTab = tab;
this.onFilterChanged();
}
@@ -315,10 +414,6 @@ export class TdsInterestReportComponent implements OnInit {
return this.expandedId === row.deductee.customerId;
}
branchName(code: string): string {
return glBranchName(code);
}
trackRow(_: number, row: TdsInterestRow): string {
return row.deductee.customerId;
}
@@ -326,92 +421,17 @@ export class TdsInterestReportComponent implements OnInit {
async exportWorkbook(): Promise<void> {
this.exporting = true;
try {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(`TDS Interest FY ${this.filter.fy}`);
ws.addRow([`Deposit Interest & TDS — FY ${this.filter.fy}`]);
ws.addRow([`Scope: ${this.scopeLabel}`]);
ws.addRow([]);
ws.addRow([
'Customer',
'CIF',
'PAN',
'Branch',
'Form',
'Band',
'Age',
'Q1',
'Q2',
'Q3',
'Q4',
'Interest (FD/RD)',
'Savings interest (exempt)',
'Threshold',
'Crossed in',
'Rate %',
'TDS payable',
'Declaration',
'UIN',
'TDS saved',
'Paid without deduction'
]);
for (const r of this.rows) {
ws.addRow([
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'NOT ON RECORD',
this.branchName(r.branchCode),
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
r.age,
r.quarters[1],
r.quarters[2],
r.quarters[3],
r.quarters[4],
r.tdsApplicableInterest,
r.exemptInterest,
r.threshold,
r.crossedInQuarter ? `Q${r.crossedInQuarter}` : '—',
r.rateApplied * 100,
r.schedule.totalTds,
r.hasDeclaration ? 'Yes' : 'No',
r.declarationUin ?? '',
r.tdsSaved,
r.paidWithoutDeduction
]);
}
const s = this.summary;
ws.addRow([]);
ws.addRow([
'TOTAL',
`${s.deductees} deductees`,
'',
'',
'',
'',
'',
s.byQuarter[0].interest,
s.byQuarter[1].interest,
s.byQuarter[2].interest,
s.byQuarter[3].interest,
s.applicableInterest,
s.exemptInterest,
'',
'',
'',
s.totalTds,
`${s.declarations} filed`,
'',
s.tdsSaved,
s.paidWithoutDeduction
]);
ws.getRow(1).font = { bold: true, size: 13 };
ws.getRow(4).font = { bold: true };
ws.columns.forEach((c) => (c.width = 18));
const wb = buildTdsInterestWorkbook({
fy: this.filter.fy,
scopeLabel: this.scopeLabel,
rules: this.rules,
statement: this.statute.statement,
// What the screen shows under the current filters, every tab included.
rows: this.rows,
declaredRows: this.declaredRows,
summary: this.summary,
statusOf: (row) => this.statusOf(row)
});
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
@@ -419,7 +439,7 @@ export class TdsInterestReportComponent implements OnInit {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TDS-Interest-FY${this.filter.fy}.xlsx`;
a.download = `TDS-Register-FY${this.filter.fy}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
@@ -13,225 +13,329 @@
<div>
<h2 class="tdsr__title">TDS Remitted to Government</h2>
<div class="tdsr__sub">
Challan-wise deposits and the {{ statute.statement }} year-end position · {{ scopeLabel }} · as at
{{ asAt | date: 'd MMM y' }}
TDS deducted from customers' deposits each month, and the challans Head Office paid it with · Bank as a whole
@if (asAt) {
· as at {{ asAt | date: 'd MMM y' }}
}
</div>
</div>
</div>
<div class="tdsr__head-r">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="fy">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
@if (isHeadOffice) {
<div class="tdsr__head-r">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="fy" (ngModelChange)="onYearChanged()">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
<button type="button" class="tdsr__btn" (click)="openForm()">
<fa-icon icon="plus"></fa-icon> Record challan
</button>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting || !summary">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
}
</div>
<!-- The control figure, first thing on the page -->
@if (reconciled) {
<div class="tdsr__banner tdsr__banner--ok">
<fa-icon icon="check-circle"></fa-icon>
@if (!isHeadOffice) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>Reconciled.</strong>
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{
summary.totalDeposited | number: '1.0-0'
}}
deposited, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
<strong>This report is for Head Office.</strong> 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
<em>Reports › Deposit Interest &amp; TDS</em>.
</div>
</div>
} @else {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.</strong>
Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the
return.
@if (loadError) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ loadError }}</div>
</div>
</div>
}
}
@if (summary.overdueChallans > 0) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>{{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue.</strong>
Interest runs at 1.5% for every month or part of a month until the tax reaches the Government.
<!-- Challan form -->
@if (showForm) {
<div class="tdsr__card tdsr__form">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Record a challan — FY {{ fy }}</div>
<div class="tdsr__filters tdsr__filters--flat">
<label class="tdsr__filter">
<span>Deduction month *</span>
<input type="month" [(ngModel)]="form.month" />
</label>
<label class="tdsr__filter">
<span>Amount (₹) *</span>
<input type="number" min="0" step="1" [(ngModel)]="form.amount" />
</label>
<label class="tdsr__filter">
<span>Challan no. *</span>
<input type="text" [(ngModel)]="form.challanNo" placeholder="e.g. 00123" />
</label>
<label class="tdsr__filter">
<span>BSR code *</span>
<input type="text" maxlength="7" [(ngModel)]="form.bsrCode" placeholder="7 digits" />
</label>
<label class="tdsr__filter">
<span>Paid on *</span>
<input type="date" [(ngModel)]="form.depositedOn" />
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Paid from account *</span>
<select [(ngModel)]="form.paidFromGlAccountId">
<option [ngValue]="0" disabled>Choose bank / cash account</option>
@for (o of paidFromOptions; track o.id) {
<option [ngValue]="o.id">{{ o.glCode }} · {{ o.name }}</option>
}
</select>
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Remarks</span>
<input type="text" [(ngModel)]="form.remarks" />
</label>
</div>
<div class="tdsr__foot">
Saving posts <strong>Dr TDS payable / Cr the account paid from</strong> at Head Office, dated the day it was
paid.
</div>
@if (formError) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ formError }}</div>
</div>
}
<div class="tdsr__form-actions">
<button type="button" class="tdsr__btn" (click)="showForm = false" [disabled]="saving">Cancel</button>
<button type="button" class="tdsr__btn tdsr__btn--primary" (click)="saveChallan()" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save challan' }}
</button>
</div>
</div>
</div>
}
}
<!-- KPIs -->
<div class="tdsr__kpis">
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalDeducted | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Total TDS deducted</div>
</div>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.totalDeposited | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Deposited with Government</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.pendingDeposit | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Within due date</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="totalDefaultInterest > 0">
<div class="tdsr__kpi-n">₹{{ totalDefaultInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on late deposit</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees in the return</div>
</div>
</div>
<!-- Part A: challans -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Part A — challan-wise remittance</div>
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Quarter</th>
<th>Deduction month</th>
<th class="num">Amount</th>
<th>Due date</th>
<th>Deposited on</th>
<th>Challan · BSR</th>
<th>Status</th>
<th class="num">Late-deposit interest</th>
<th>{{ statute.statement }} due</th>
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="row.late || row.overdue">
<td>
<strong>{{ quarterLabel[row.quarter] }}</strong>
</td>
<td class="small">{{ row.month }}</td>
<td class="num">
<strong>₹{{ row.amount | number: '1.0-0' }}</strong>
</td>
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
<td class="small">
@if (row.depositedOn) {
{{ row.depositedOn | date: 'd MMM y' }}
} @else {
<span class="muted">—</span>
}
</td>
<td class="mono small">
@if (row.challanNo) {
{{ row.challanNo }}
<div class="muted">BSR {{ row.bsrCode }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (row.overdue) {
<span class="tdsr__pill tdsr__pill--warn">Overdue</span>
} @else if (row.late) {
<span class="tdsr__pill tdsr__pill--warn">Late by {{ row.delayMonths }}m</span>
} @else if (row.depositedOn) {
<span class="tdsr__pill tdsr__pill--ok">On time</span>
} @else {
<span class="tdsr__pill">Within due date</span>
}
</td>
<td class="num">
@if (row.defaultInterest) {
<strong class="tdsr__warn-text">₹{{ row.defaultInterest | number: '1.0-0' }}</strong>
<div class="small muted">1.5% × {{ row.delayMonths }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ row.returnDueDate }}</td>
</tr>
}
@if (rows.length === 0) {
<tr>
<td colspan="9" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No tax was deducted in this financial year.
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="tdsr__foot">
Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
</div>
</div>
<!-- Part B: return summary -->
<div class="tdsr__card">
<div class="tdsr__card-head">
<fa-icon icon="file-alt"></fa-icon> Part B — {{ statute.statement }} year-end position
</div>
<div class="tdsr__ledger">
<div class="tdsr__ledger-row">
<span>Total interest paid on time deposits</span>
<span class="num">₹{{ summary.totalInterestPaid | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>with tax deducted</span>
<span class="num">₹{{ summary.interestWithDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>
without deduction — declaration on record
<em>mandatory disclosure in {{ statute.statement }}</em>
</span>
<span class="num">₹{{ summary.interestWithoutDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>TDS at standard rate — PAN on record</span>
<span class="num">₹{{ summary.tdsAtStandardRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>TDS at higher rate — no PAN on record</span>
<span class="num">₹{{ summary.tdsAtHigherRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--total">
<span>Total TDS deducted</span>
<span class="num">₹{{ summary.totalDeducted | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Deposited with Government</span>
<span class="num">₹{{ summary.totalDeposited | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Still within due date</span>
<span class="num">₹{{ summary.pendingDeposit | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--control" [class.tdsr__ledger-row--bad]="!reconciled">
<span>Difference — must be zero</span>
<span class="num">₹{{ summary.difference | number: '1.0-0' }}</span>
</div>
@if (summary.defaultInterest > 0) {
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>Interest payable by the bank for late deposit</span>
<span class="num">₹{{ summary.defaultInterest | number: '1.0-0' }}</span>
@if (summary) {
<!-- The control figure, first thing on the page -->
@if (reconciled) {
<div class="tdsr__banner tdsr__banner--ok">
<fa-icon icon="check-circle"></fa-icon>
<div>
<strong>Reconciled.</strong>
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ summary.totalDeposited | number: '1.0-0' }}
paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
</div>
</div>
} @else {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.</strong>
Tax deducted must equal tax 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.
}
</div>
</div>
}
@if (summary.calculatedTds !== summary.totalDeducted) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
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
<em>Deposit Interest &amp; TDS</em> to see which customers differ.
</div>
</div>
}
<!-- KPIs -->
<div class="tdsr__kpis">
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalDeducted | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS deducted from accounts</div>
</div>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.totalDeposited | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Paid to Government</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.pendingDeposit | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">To pay — still within due date</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="summary.overdueAmount > 0">
<div class="tdsr__kpi-n">₹{{ summary.overdueAmount | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Overdue — pay now</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="summary.defaultInterest > 0">
<div class="tdsr__kpi-n">₹{{ summary.defaultInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest for paying late</div>
</div>
</div>
}
<!-- Part A: month by month -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Part A — month by month</div>
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Quarter</th>
<th>Deduction month</th>
<th class="num">Deducted</th>
<th>Pay by</th>
<th>Challans</th>
<th class="num">Paid</th>
<th class="num">Still to pay</th>
<th>Status</th>
<th class="num">Late interest</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="row.late || row.overdue">
<td class="small"><strong>{{ quarterLabel[row.quarter] }}</strong></td>
<td class="small">
{{ row.month + '-01' | date: 'MMM y' }}
<div class="muted">{{ row.postings }} deduction{{ row.postings === 1 ? '' : 's' }}</div>
</td>
<td class="num"><strong>₹{{ row.deducted | number: '1.0-2' }}</strong></td>
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
<td class="mono small">
@for (c of row.challans; track c.id) {
<div>
{{ c.challanNo }} · BSR {{ c.bsrCode }} · {{ c.depositedOn | date: 'd MMM y' }} · ₹{{
c.amount | number: '1.0-2'
}}
<button type="button" class="tdsr__link" (click)="reverseChallan(c)">reverse</button>
</div>
} @empty {
<span class="muted">—</span>
}
</td>
<td class="num">₹{{ row.deposited | number: '1.0-2' }}</td>
<td class="num">
@if (row.outstanding) {
<strong>₹{{ row.outstanding | number: '1.0-2' }}</strong>
} @else if (row.excess) {
<span class="tdsr__warn-text small">₹{{ row.excess | number: '1.0-2' }} paid extra</span>
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (row.overdue) {
<span class="tdsr__pill tdsr__pill--warn">Overdue</span>
} @else if (row.late) {
<span class="tdsr__pill tdsr__pill--warn">Paid late by {{ row.delayMonths }}m</span>
} @else if (row.outstanding) {
<span class="tdsr__pill">Within due date</span>
} @else {
<span class="tdsr__pill tdsr__pill--ok">Paid on time</span>
}
</td>
<td class="num">
@if (row.defaultInterest) {
<strong class="tdsr__warn-text">₹{{ row.defaultInterest | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
@if (row.outstanding) {
<button type="button" class="tdsr__btn tdsr__btn--sm" (click)="openForm(row)">Pay</button>
}
</td>
</tr>
}
@if (rows.length === 0) {
<tr>
<td colspan="10" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon>
{{ loading ? 'Loading…' : 'No TDS was deducted from any account in this financial year.' }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="tdsr__foot">
Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
Interest runs at 1.5% for every month or part of a month it is late.
</div>
</div>
<div class="tdsr__foot">
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the
quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of
declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
specification before generating the file.
</div>
</div>
<!-- Part B: return summary -->
@if (summary) {
<div class="tdsr__card">
<div class="tdsr__card-head">
<fa-icon icon="file-alt"></fa-icon> Part B — {{ statute.statement }} year-end position
</div>
<div class="tdsr__ledger">
<div class="tdsr__ledger-row">
<span>Deductees (customers with an FD/RD)</span>
<span class="num">{{ summary.deductees | number }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Total interest paid on time deposits</span>
<span class="num">₹{{ summary.totalInterestPaid | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>with tax deducted</span>
<span class="num">₹{{ summary.interestWithDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>
without deduction — declaration on record
<em>mandatory disclosure in {{ statute.statement }}</em>
</span>
<span class="num">₹{{ summary.interestWithoutDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>TDS at standard rate — PAN on record</span>
<span class="num">₹{{ summary.tdsAtStandardRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>TDS at higher rate — no PAN on record</span>
<span class="num">₹{{ summary.tdsAtHigherRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--total">
<span>Total TDS deducted</span>
<span class="num">₹{{ summary.totalDeducted | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Paid to Government</span>
<span class="num">₹{{ summary.totalDeposited | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Still within due date</span>
<span class="num">₹{{ summary.pendingDeposit | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--control" [class.tdsr__ledger-row--bad]="!reconciled">
<span>Difference — must be zero</span>
<span class="num">₹{{ summary.difference | number: '1.0-0' }}</span>
</div>
@if (summary.defaultInterest > 0) {
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>Interest payable by the bank for late deposit</span>
<span class="num">₹{{ summary.defaultInterest | number: '1.0-0' }}</span>
</div>
}
</div>
<div class="tdsr__foot">
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the
quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of
declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
specification before generating the file.
</div>
</div>
}
}
</div>
@@ -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<void> {
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);
+27 -167
View File
@@ -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<string, Declaration>;
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<T>(rand: () => number, list: T[]): T {
return list[Math.floor(rand() * list.length)];
}
const FIRST_NAMES = [
'Ramesh',
'Sunita',
'Vitthal',
'Kamala',
'Ganpat',
'Shobha',
'Dnyaneshwar',
'Mangala',
'Bhaskar',
'Anita',
'Tukaram',
'Sarika',
'Nivrutti',
'Rekha',
'Popat',
'Chhaya',
'Ashok',
'Vaishali',
'Sitaram',
'Jayshree',
'Madhukar',
'Pushpa',
'Eknath',
'Sulochana',
'Hanumant',
'Kalpana'
];
const SURNAMES = [
'Jadhav',
'Pawar',
'Shinde',
'Gaikwad',
'More',
'Kadam',
'Bhosale',
'Patil',
'Sawant',
'Chavan',
'Salunkhe',
'Mane',
'Deshmukh',
'Thorat',
'Nikam',
'Wagh'
];
/**
* Quarter-end dates for a financial year. Interest is credited on these, which
* is what makes the deduction fall due.
*/
function quarterEnds(fy: string): string[] {
const y = Number(fy.slice(0, 4));
return [
`${y}-06-30`,
`${y}-09-30`,
`${y}-12-31`,
`${y + 1}-03-31`
];
}
/** A well-formed individual PAN — fourth character 'P'. */
function makePan(rand: () => number, surname: string): string {
const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26));
const digits = `${1000 + Math.floor(rand() * 9000)}`;
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
/** 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<string, TdsChallan[]> | null = null;
/**
* The remittance schedule, derived from what the engine says was deducted.
*
* Deductions land at each quarter end, so there is one challan per quarter. The
* Q2 challan of the prior year is deliberately deposited a month and a bit late
* so the late-deposit interest column is exercised, and the current year's latest
* quarter is left pending, which is the ordinary state of a live book.
*/
export function tdsChallans(deductedByQuarter: Record<string, Record<number, number>>): Record<string, TdsChallan[]> {
if (challanCache) {
return challanCache;
}
const out: Record<string, TdsChallan[]> = {};
for (const fy of Object.keys(deductedByQuarter)) {
const y = Number(fy.slice(0, 4));
const months: Record<number, string> = {
1: `${y}-06`,
2: `${y}-09`,
3: `${y}-12`,
4: `${y + 1}-03`
};
const rows: TdsChallan[] = [];
for (const q of [
1,
2,
3,
4
] as (1 | 2 | 3 | 4)[]) {
const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0);
if (amount <= 0) {
continue;
}
const month = months[q];
const dueDate = depositDueDate(month);
// Anything falling after the business date has not been deposited yet.
const pending = dueDate > TDS_AS_AT;
const late = fy === '2025-26' && q === 2;
rows.push({
fy,
quarter: q,
month,
amount,
dueDate,
depositedOn: pending ? null : late ? `${y}-11-14` : dueDate,
challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`,
bsrCode: pending ? null : '0510308'
});
}
out[fy] = rows;
}
challanCache = out;
return out;
}

Some files were not shown because too many files have changed in this diff Show More