diff --git a/package.json b/package.json index 4e0057794..aad5a8105 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "ng": "ng", "build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none", "build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/", - "start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js", + "start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" ng serve --proxy-config proxy.localhost.conf.js", "start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js", "start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js", "serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js", diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html index 6938ce4bd..899514486 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html @@ -279,7 +279,7 @@
- + @@ -288,7 +288,7 @@
- + @@ -353,13 +353,7 @@
- + @@ -368,7 +362,7 @@
- + @@ -539,7 +533,7 @@
- + # diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts index cf8330511..a6b2aa257 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts @@ -90,7 +90,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView // ── Misc ────────────────────────────────────────────────────────────────── minDate = new Date(2000, 0, 1); - maxDate = new Date(); + maxDate: Date = null; /** Accounting features switched off for this installation — see disabled-features.ts. */ disabledFeatures = DISABLED_ACCOUNTING_FEATURES; @@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView } ngOnInit() { - this.maxDate = this.settingsService.businessDate; + this.maxDate = null; this.paginator.pageSize = 10; this.restoreState(); this.setFilteredOffices(); diff --git a/src/app/accounts/accounts.service.ts b/src/app/accounts/accounts.service.ts index ad7e49688..ddc9efadc 100644 --- a/src/app/accounts/accounts.service.ts +++ b/src/app/accounts/accounts.service.ts @@ -74,6 +74,17 @@ export class AccountsService { return this.http.get('/fixeddepositaccounts', { params: httpParams }); } + /** + * FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date + * and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than + * getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit + * who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns + * that had no index until this feature added one (see backend migration 0571). + */ + getFixedDepositMaturityDashboard(): Observable { + return this.http.get('/fixeddepositaccounts/maturity-dashboard'); + } + getRecurringDepositAccounts(params: AccountListParams = {}): Observable { let httpParams = new HttpParams() .set('paged', 'true') diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4136ae258..b95fd0ad1 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -63,6 +63,7 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module'; import { BankCalendarModule } from './bank-calendar/bank-calendar.module'; import { DeafModule } from './deaf/deaf.module'; import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module'; +import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module'; import { DicgcModule } from './dicgc/dicgc.module'; import { FiuModule } from './fiu/fiu.module'; import { GlReportsModule } from './gl-reports/gl-reports.module'; @@ -176,6 +177,7 @@ export function HttpLoaderFactory(http: HttpClient) { BankCalendarModule, DeafModule, SavingsDailyInterestModule, + FdDashboardModule, DicgcModule, FiuModule, GlReportsModule, diff --git a/src/app/board-resolution/bor-report.component.html b/src/app/board-resolution/bor-report.component.html index ec7b8bf32..1fce84acf 100644 --- a/src/app/board-resolution/bor-report.component.html +++ b/src/app/board-resolution/bor-report.component.html @@ -17,7 +17,12 @@

- @@ -132,7 +137,9 @@ {{ r.approvedShares | number }} {{ r.shareValue | number: '1.2-2' }} {{ r.trancheCount }} - {{ statusText(r.statusEnum) }} + + {{ statusText(r.statusEnum) }} + {{ r.activatedDate || '—' }} } @empty { diff --git a/src/app/cashier/account-register/account-register.component.ts b/src/app/cashier/account-register/account-register.component.ts index 683841a11..06ce5e73d 100644 --- a/src/app/cashier/account-register/account-register.component.ts +++ b/src/app/cashier/account-register/account-register.component.ts @@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; interface RegisterRow { accountId: number; @@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit { /** Local date — toISOString() would shift the day in any timezone behind UTC. */ private today(): string { - const d = new Date(); + const d = businessToday(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index 65d27eb6e..57d49000e 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -107,11 +107,26 @@
@if (canOperate) { - }
+ @if (interestPending.length > 0) { +
+ warning + + Savings interest posting report is not approved for {{ interestPending.join(', ') }}. Approve it in Reports → Interest Posting Reports before + closing the day. + +
+ } } @else if (loaded && canOperate) {
diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss index c97aebcc9..449dfced1 100644 --- a/src/app/cashier/branch-day/branch-day.component.scss +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -372,3 +372,30 @@ color: #d97706; } } + +// Shown under the open day while a savings interest posting report due today is unapproved. +.bd-interest-alert { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 8px 0 12px; + padding: 10px 14px; + border: 1px solid #f59e0b; + border-radius: 8px; + background: #fef3c7; + color: #92400e; + font-size: 13px; + + mat-icon { + flex-shrink: 0; + font-size: 20px; + width: 20px; + height: 20px; + } + + a { + color: inherit; + font-weight: 600; + text-decoration: underline; + } +} diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts index da476f658..088380db1 100644 --- a/src/app/cashier/branch-day/branch-day.component.ts +++ b/src/app/cashier/branch-day/branch-day.component.ts @@ -189,6 +189,23 @@ export class BranchDayComponent implements OnInit { return this.totalOf(this.closeCounts); } + /** + * Savings products whose interest posting report is due today and not yet approved. The close is + * refused by the backend while any is pending; this only says so before the user tries. + */ + interestPending: string[] = []; + + private checkInterestReports(): void { + if (!this.openDay?.businessDate) { + this.interestPending = []; + return; + } + this.service + .interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId) + .pipe(catchError(() => of(null))) + .subscribe((res) => (this.interestPending = res?.pendingProducts ?? [])); + } + load(): void { this.loading = true; this.errorMsg = ''; @@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit { this.canOperate = !!res?.canOperate; this.openDate = this.today; this.checkFirstOpenCashMigration(); + this.checkInterestReports(); }); this.service diff --git a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts index 65fbf5d25..4ddc24e33 100644 --- a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts +++ b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts @@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { AuthenticationService } from '../../core/authentication/authentication.service'; import { ExportSheetSpec, exportWorkbook } from '../cashier-export'; +import { businessToday } from 'app/core/utils/business-today'; interface SummaryRow { code: string; @@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent { private dateTag = '2607'; constructor() { - const d = new Date(); + const d = businessToday(); this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`; diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts index 383cb57d6..bede34b4b 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts @@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; interface Denom { id: number; @@ -63,7 +64,7 @@ export class CashExchangeEntryComponent implements OnInit { } ngOnInit(): void { - const d = new Date(); + const d = businessToday(); this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.service.getActiveDenominations().subscribe({ next: (list) => { diff --git a/src/app/cashier/cash-settlement/cash-settlement.component.ts b/src/app/cashier/cash-settlement/cash-settlement.component.ts index 45d5c0f93..122527d6f 100644 --- a/src/app/cashier/cash-settlement/cash-settlement.component.ts +++ b/src/app/cashier/cash-settlement/cash-settlement.component.ts @@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form'; @@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit { saving = false; ngOnInit(): void { - const d = new Date(); + const d = businessToday(); this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.service .getTellers() diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.ts b/src/app/cashier/cash-transfer/cash-transfer.component.ts index fa5de03f9..d818ad5e2 100644 --- a/src/app/cashier/cash-transfer/cash-transfer.component.ts +++ b/src/app/cashier/cash-transfer/cash-transfer.component.ts @@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input'; import { of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; interface Denom { id: number; @@ -82,7 +83,7 @@ export class CashTransferComponent implements OnInit { loadingVault = false; ngOnInit(): void { - const d = new Date(); + const d = businessToday(); this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.service.getActiveDenominations().subscribe({ next: (list) => { diff --git a/src/app/cashier/cashier-position/cashier-position.component.ts b/src/app/cashier/cashier-position/cashier-position.component.ts index 1ffa957b1..dc0025bdd 100644 --- a/src/app/cashier/cashier-position/cashier-position.component.ts +++ b/src/app/cashier/cashier-position/cashier-position.component.ts @@ -17,6 +17,7 @@ import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { businessToday } from 'app/core/utils/business-today'; interface DenomRow { denominationId: number; @@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit { isCashierMode = false; ngOnInit(): void { - const d = new Date(); + const d = businessToday(); this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.isCashierMode = this.authService.isCashierUser(); diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 3078701f8..f3700e20c 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -205,6 +205,23 @@ export class CashierService { return this.http.get('/branchday', { params }); } + /** + * The savings products whose interest posting report is due for the date and not yet approved — + * the backend refuses to close any branch's day while one is pending. + */ + interestDayCloseCheck( + date?: string, + officeId?: number + ): Observable<{ businessDate: string; canClose: boolean; pendingProducts: string[] }> { + let params = new HttpParams(); + if (date) params = params.set('date', date); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get<{ businessDate: string; canClose: boolean; pendingProducts: string[] }>( + '/savingsinterestpostingreports/day-close-check', + { params } + ); + } + /** Days opened and never closed — today's own open day excluded. */ branchDayMissed(officeId?: number): Observable { let params = new HttpParams(); diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts index 929a2af27..cc31a58f6 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts @@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; /** Day-wise denomination-wise closing cash collected from deposits. */ @Component({ @@ -204,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit { } ngOnInit(): void { - const d = new Date(); + const d = businessToday(); this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.load(); this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} }); diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts index 8ce3c0436..05fb8e475 100644 --- a/src/app/cashier/day-book/day-book.component.ts +++ b/src/app/cashier/day-book/day-book.component.ts @@ -13,6 +13,7 @@ import { MatIconModule } from '@angular/material/icon'; import { Router } from '@angular/router'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; /** * Which part of the day's work a row came from. Set by the server, one per source register. @@ -95,7 +96,7 @@ export class DayBookComponent implements OnInit { /** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */ private today(): string { - const d = new Date(); + const d = businessToday(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } diff --git a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts index 047384c84..e2b204491 100644 --- a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts +++ b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts @@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input'; import { of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; interface Denom { id: number; @@ -79,7 +80,7 @@ export class InterCashierTransferComponent implements OnInit { } ngOnInit(): void { - const d = new Date(); + const d = businessToday(); this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.service.getActiveDenominations().subscribe({ next: (list) => { diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts index 4dbee818f..8c0cf4bd4 100644 --- a/src/app/cashier/my-work/my-work.component.ts +++ b/src/app/cashier/my-work/my-work.component.ts @@ -14,6 +14,7 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { catchError, finalize, of } from 'rxjs'; import { CashierService } from '../cashier.service'; import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component'; +import { businessToday } from 'app/core/utils/business-today'; /** What the signed-in user's part in a voucher was. */ export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED'; @@ -357,7 +358,7 @@ export class MyWorkComponent implements OnInit { } private todayIso(): string { - const d = new Date(); + const d = businessToday(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } } diff --git a/src/app/cashier/reconciliation/reconciliation.component.ts b/src/app/cashier/reconciliation/reconciliation.component.ts index 40e4eeae2..a41c15412 100644 --- a/src/app/cashier/reconciliation/reconciliation.component.ts +++ b/src/app/cashier/reconciliation/reconciliation.component.ts @@ -18,6 +18,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; interface DenomRow { denominationId: number; @@ -85,7 +86,7 @@ export class ReconciliationComponent implements OnInit { loadingHistory = false; ngOnInit(): void { - const d = new Date(); + const d = businessToday(); this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.service .getTellers() diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index aa4c8ca6f..aca25b5d5 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -33,6 +33,7 @@ import { import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service'; import { OrganizationService } from '../../organization/organization.service'; import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component'; +import { businessToday, businessTodayIso } from 'app/core/utils/business-today'; interface TxnRow { typ: string; @@ -621,7 +622,7 @@ export class AccountStatementDialogComponent implements OnInit { @ViewChild('statementContent') statementContent!: ElementRef; - today = new Date().toISOString().slice(0, 10); + today = businessTodayIso(); fromDate = ''; toDate = this.today; @@ -1870,7 +1871,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { saving = false; ngOnInit(): void { - const d = new Date(); + const d = businessToday(); this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; this.addRow(); this.loadDenominations(); @@ -2103,7 +2104,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } private loadOpeningPosition(): void { - const d = new Date(); + const d = businessToday(); const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.cashierService .getCashierPositionForMe(asOfDate) @@ -2837,7 +2838,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { fundingAccountId: number, fundingClientId: number | null ): void { - const d = new Date(); + const d = businessToday(); const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB'; const isGl = row.acTyp === 'GL'; @@ -2969,7 +2970,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - const d = new Date(); + const d = businessToday(); const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB'; @@ -3068,7 +3069,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - const d = new Date(); + const d = businessToday(); const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const payload = { @@ -3391,7 +3392,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - const today = new Date(); + const today = businessToday(); const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; // Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim @@ -3512,7 +3513,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - const today = new Date(); + const today = businessToday(); const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; // Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim @@ -4173,7 +4174,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ): void { const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`; const todayStr = (() => { - const d = new Date(); + const d = businessToday(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; })(); @@ -4549,7 +4550,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } get todayIso(): string { - const d = new Date(); + const d = businessToday(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } @@ -5071,7 +5072,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.sameDayChequeClearing = 0; return; } - const today = new Date(); + const today = businessToday(); const sameDay = (d: any) => { if (!d) return false; const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d); @@ -5959,7 +5960,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - const today = new Date(); + const today = businessToday(); const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`; const payload: any = { @@ -6041,7 +6042,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.editRequest = req; } - const acTyp = 'SD'; + // Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the + // request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or + // RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the + // wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not + // found". + const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD'; const row = this.rows[0]; row.acNo = req.account_no ?? ''; row.acTyp = acTyp; @@ -6149,7 +6155,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { /** Build the withdrawal-request payload from the current row + loaded account. */ private buildWithdrawalPayload(row: TxnRow, amount: number): any { - const today = new Date(); + const today = businessToday(); const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId); @@ -6254,7 +6260,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { /** Build the deposit-request payload from the current row + loaded account. */ private buildDepositPayload(row: TxnRow, amount: number): any { - const today = new Date(); + const today = businessToday(); const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId); // Send denominations only for cash payments — stale counts on a cheque @@ -6365,7 +6371,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - const today = new Date(); + const today = businessToday(); const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`; const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; diff --git a/src/app/cashier/vault-position/vault-position.component.ts b/src/app/cashier/vault-position/vault-position.component.ts index d91cbdb2d..064d8b58d 100644 --- a/src/app/cashier/vault-position/vault-position.component.ts +++ b/src/app/cashier/vault-position/vault-position.component.ts @@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input'; import { of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; interface DenomRow { denominationId: number; @@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit { errorMsg = ''; ngOnInit(): void { - const d = new Date(); + const d = businessToday(); this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.service diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts index 8444b2813..d6c148d91 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.ts +++ b/src/app/cashier/voucher-report/voucher-report.component.ts @@ -13,6 +13,7 @@ import { ActivatedRoute } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; import { catchError, of } from 'rxjs'; import { CashierService } from '../cashier.service'; +import { businessToday } from 'app/core/utils/business-today'; /** * Which side of the register a screen shows. @@ -227,7 +228,7 @@ export class VoucherReportComponent implements OnInit { } private todayIso(): string { - const now = new Date(); + const now = businessToday(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; } diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index ed53470c4..db39bae63 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -643,8 +643,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.createClientForm.get('degreeQualificationId')?.setValue(''); } }); - // Fetch all constitution options from code ID 17 - this.clientService.getCodeValuesById(17).subscribe((options) => { + // Constitution options by code NAME, never by id: code ids differ per tenant (on abc_bank id 17 is the empty + // ClientClassification code and Constitution is id 24), so a hardcoded id left the dropdown empty. + this.clientService.getCodeValuesByName('Constitution').subscribe((options) => { if (options?.length) { this.constitutionOptions = options; // Sort master by value length descending so longer/more-specific keys match first diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html index f139711f3..65d2d97da 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html @@ -1321,6 +1321,11 @@ kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched ) { Only letters, numbers, hyphens and slashes allowed + } @else if ( + kycForm.get('kycDocumentNumber')?.hasError('aadhaarLast4Mismatch') && + kycForm.get('kycDocumentNumber')?.touched + ) { + Last 4 digits do not match the Aadhaar number entered above } diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index 00d2afccd..cfca4fb8c 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -8,7 +8,14 @@ import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core'; import { forkJoin, catchError, of } from 'rxjs'; -import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms'; +import { + UntypedFormBuilder, + UntypedFormGroup, + Validators, + FormsModule, + AbstractControl, + ValidationErrors +} from '@angular/forms'; import { MatDialog, MAT_DIALOG_DATA, @@ -567,6 +574,28 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { /** Existing signature document ID (for re-upload/delete) */ existingSignatureDocId: string = ''; + /** + * Cross-field check for the OVD "Document Number" field (kycDocumentNumber): when Aadhaar Card + * is the selected KYC document, its value must identify the same Aadhaar as the aadhaarNumber + * field captured earlier in this step - the last 4 digits are what's compared, since + * aadhaarNumber may hold either the raw 12-digit number or a masked value (masking only ever + * obscures the first 8 digits - see aadhaarMasked in the kycData getter below - so the last 4 + * characters are always the real digits either way). Not applicable to any other document type, + * where kycDocumentNumber is unrelated to Aadhaar entirely. + */ + private aadhaarLast4MatchValidator = (control: AbstractControl): ValidationErrors | null => { + if (this.selectedKycDoc?.code !== 'AADHAAR') { + return null; + } + const docNumber = (control.value || '').toString().trim(); + const aadhaarValue = (control.parent?.get('aadhaarNumber')?.value || '').toString().trim(); + if (docNumber.length < 4 || aadhaarValue.length < 4) { + // Required/pattern validators already flag an empty or too-short value on their own. + return null; + } + return docNumber.slice(-4) === aadhaarValue.slice(-4) ? null : { aadhaarLast4Mismatch: true }; + }; + /** KYC Form */ kycForm: UntypedFormGroup = this.formBuilder.group({ hasPan: [true], @@ -616,7 +645,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { [ Validators.required, Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/), - Validators.maxLength(30) + Validators.maxLength(30), + this.aadhaarLast4MatchValidator ] ], kycDocIssueDate: [''], @@ -739,6 +769,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.kycForm.get('ecgcSalFlag')?.disable(); this.kycForm.get('dormantFlag')?.disable(); } + + // aadhaarLast4MatchValidator (on kycDocumentNumber) reads this control's value directly rather + // than through its own valueChanges, so a re-check has to be triggered explicitly whenever the + // Aadhaar field itself changes - otherwise fixing aadhaarNumber after kycDocumentNumber was + // already entered would leave a stale mismatch error in place. + this.kycForm.get('aadhaarNumber')?.valueChanges.subscribe(() => { + this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true }); + }); } /** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs, @@ -1009,6 +1047,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { // Reset uploads + merged preview when document type changes this.kycDocFiles = { front: null, back: null }; this.kycDocMergedPreview = null; + // aadhaarLast4MatchValidator only applies while Aadhaar Card is selected - re-check so switching + // to/away from it immediately picks up (or clears) the mismatch error. + this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true }); } isKycDocSelected(proof: ProofType): boolean { @@ -1189,6 +1230,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { // Sync chip-picker selection with the prefilled document name if (data.kycDocumentName) { this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName); + // patchValue above ran kycDocumentNumber's validators before selectedKycDoc was known, + // so aadhaarLast4MatchValidator needs a fresh check now that it is. + this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true }); } // Set verification statuses from existing data if (data.panVerified) { diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts index 22ee81703..cb7b21e06 100644 --- a/src/app/clients/clients-routing.module.ts +++ b/src/app/clients/clients-routing.module.ts @@ -61,6 +61,7 @@ import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component'; import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component'; import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component'; import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component'; +import { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service'; import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component'; import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component'; import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard'; @@ -198,6 +199,7 @@ const routes: Routes = [ { path: 'form121', component: Form121TabComponent, + resolve: { tdsConfig: tdsConfigResolver }, data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false } }, { diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html index 4f71a5b6d..4beac80ce 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html @@ -202,9 +202,14 @@
Already available from {{ existing.ADDRESS_PROOF.source }} + @if (existing.ADDRESS_PROOF.previewUnavailable) { + stored file unavailable + }
- @if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) { + @if ( + (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) && isImage('ADDRESS_PROOF') + ) {
@if (existing.ADDRESS_PROOF.previewUrl) { } + @if (existing.ADDRESS_PROOF.previewUrl) { + + }
{{ existing.ADDRESS_PROOF.name }} @@ -299,9 +315,12 @@
Already available from {{ existing.PHOTO_PROOF.source }} + @if (existing.PHOTO_PROOF.previewUnavailable) { + stored file unavailable + }
- @if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) { + @if ((existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) && isImage('PHOTO_PROOF')) {
@if (existing.PHOTO_PROOF.previewUrl) { } + @if (existing.PHOTO_PROOF.previewUrl) { + + }
{{ existing.PHOTO_PROOF.name }} @@ -393,11 +423,25 @@
Already available from {{ existing.CLIENT_IMAGE.source }} + @if (existing.CLIENT_IMAGE.previewUnavailable) { + stored file unavailable + }
- @if (existing.CLIENT_IMAGE.previewUrl) { + @if (existing.CLIENT_IMAGE.previewUrl && isImage('CLIENT_IMAGE')) { Client photo } + @if (existing.CLIENT_IMAGE.previewUrl) { + + }
- @if (existing.CLIENT_SIGNATURE.previewUrl) { + @if (existing.CLIENT_SIGNATURE.previewUrl && isImage('CLIENT_SIGNATURE')) { Client signature } + @if (existing.CLIENT_SIGNATURE.previewUrl) { + + }
{{ existing.CLIENT_SIGNATURE.name }} diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss index 602d2dd3e..94b6f1336 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss @@ -623,6 +623,35 @@ } } +/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is + the only way to inspect a PDF - which cannot render as an inline . */ +.ckyc-existing__view { + align-self: flex-start; + color: #1d6fd0; + border-color: #bcd8f5; + background: #fff; + font-size: 13px; + font-weight: 600; + + &:hover { + background: #eef6ff; + } +} + +/* Shown when a document row exists but its file could not be fetched from the content store, + so "Already available" is not silently misleading. */ +.ckyc-existing__warn { + display: inline-block; + margin-left: 8px; + padding: 1px 8px; + border-radius: 10px; + background: #fdecec; + border: 1px solid #f3c2c2; + color: #a12b2b; + font-size: 11px; + font-weight: 600; +} + .ckyc-existing { display: flex; flex-direction: column; diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts index 0acbbf2d8..b90ec1d8c 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts @@ -36,11 +36,41 @@ interface ExistingArtefact { * already writes/reads for address proof; now shared by the photo/identity proof too. */ description?: string; fileCount?: number; + /** Set when the stored file could not be fetched - the row exists but its content is gone. */ + previewUnavailable?: boolean; + /** Stored MIME type, so the card knows whether it can render an inline thumbnail. */ + mimeType?: string; } type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF'; type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; +/** + * Client-side upload guards, kept deliberately in step with the backend so a file that passes here + * is not rejected server-side: + * - 5 MB matches `spring.servlet.multipart.max-file-size` + * - the extension list is the intersection of the backend `fineract.content.regex-whitelist` + * with what these cards can actually render. + * The dialog's `accept=` is only a file-picker hint and is trivially bypassed, so the real check + * runs here, on the file the dialog hands back, before anything is sent. + */ +const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; +const IMAGE_EXTENSIONS = [ + 'png', + 'jpeg', + 'jpg' +]; +const DOCUMENT_EXTENSIONS = [ + ...IMAGE_EXTENSIONS, + 'pdf' +]; + +/** Cards that render an inline , so a PDF would not display in them. */ +const IMAGE_ONLY_KINDS: CkycDocKind[] = [ + 'CLIENT_IMAGE', + 'CLIENT_SIGNATURE' +]; + @Component({ selector: 'mifosx-ckyc-tab', templateUrl: './ckyc-tab.component.html', @@ -150,6 +180,11 @@ export class CkycTabComponent implements OnInit, OnDestroy { ngOnInit(): void { this.route.parent.params.subscribe((params: any) => { this.clientId = params['clientId']; + // Angular reuses this component when only the clientId in the route changes, and + // loadExistingArtefacts() only ever *assigns* what it finds - it has no "not found" branch. + // Without clearing first, a client with no photo (or no signature, or no proof) keeps showing + // the previous client's card. + this.resetArtefactState(); this.loadClientInfo(); this.loadAccounts(); this.loadCkycData(); @@ -157,6 +192,46 @@ export class CkycTabComponent implements OnInit, OnDestroy { }); } + /** + * Returns every per-client card to its empty state, so nothing from the client just viewed can + * survive into the next one. Covers all four cards plus the details staged for an upload - a file + * or document number left over from another client must never be submitted against this one. + */ + private resetArtefactState(): void { + this.revokePreviewUrls(); + this.existing = { + ADDRESS_PROOF: { present: false }, + PHOTO_PROOF: { present: false }, + CLIENT_IMAGE: { present: false }, + CLIENT_SIGNATURE: { present: false } + }; + this.selectedProofType = { ADDRESS_PROOF: null, PHOTO_PROOF: null }; + this.docFiles = { + ADDRESS_PROOF: { front: null, back: null }, + PHOTO_PROOF: { front: null, back: null } + }; + this.documentDetails = { + ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null }, + PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null } + }; + this.clientImageFile = null; + this.clientSignatureFile = null; + this.uploadErrors = { + ADDRESS_PROOF: null, + PHOTO_PROOF: null, + CLIENT_IMAGE: null, + CLIENT_SIGNATURE: null + }; + } + + /** Frees the blob URLs held by the current cards before their references are dropped. */ + private revokePreviewUrls(): void { + Object.values(this.existing).forEach((e) => { + if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl); + if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack); + }); + } + private loadClientInfo(): void { this.route.parent.data.subscribe((data: any) => { const cv = data?.clientViewData; @@ -280,19 +355,29 @@ export class CkycTabComponent implements OnInit, OnDestroy { present: true, source: 'Client registration', name: signatureDoc.fileName || 'Client signature', - documentId: signatureDoc.id + documentId: signatureDoc.id, + mimeType: signatureDoc.type }; - // Fetch blob for preview + // Same endpoint getClientSignatureImage() uses, but opted out of the global error alert: + // a m_document row whose file is no longer in the content store returns 500, and the card + // below reports that itself. Without `silent` the interceptor pops an "Internal Server + // Error" modal on every page load, for a failure the user cannot act on. this.clientsService - .getClientSignatureImage(this.clientId, signatureDoc.id) + .downloadClientDocument(this.clientId, String(signatureDoc.id), true) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (blob: any) => { - if (blob) { + if (blob && blob.size > 0) { this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); + } else { + // A 200 with an empty body still means there is nothing to show. Without this the + // card would claim "Already available" with no thumbnail and no explanation. + this.existing.CLIENT_SIGNATURE.previewUnavailable = true; } }, - error: () => undefined + error: () => { + this.existing.CLIENT_SIGNATURE.previewUnavailable = true; + } }); } @@ -364,6 +449,7 @@ export class CkycTabComponent implements OnInit, OnDestroy { const list = Array.isArray(docs) ? docs : []; if (!list.length) return; this.existing[kind].fileCount = list.length; + this.existing[kind].previewUnavailable = false; const byNewest = (a: any, b: any) => b.id - a.id; const label = (d: any) => (d?.name || d?.fileName || '').toString(); @@ -372,6 +458,9 @@ export class CkycTabComponent implements OnInit, OnDestroy { [...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] || [...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0]; + // A proof can be scanned to PDF, which no can render - remember what was stored so the + // card offers "View document" instead of a broken thumbnail. + if (front) this.existing[kind].mimeType = front.type; if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front'); if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back'); }); @@ -388,12 +477,19 @@ export class CkycTabComponent implements OnInit, OnDestroy { .pipe(takeUntil(this.destroy$)) .subscribe({ next: (blob: any) => { - if (!blob) return; + if (!blob || blob.size === 0) { + this.existing[kind].previewUnavailable = true; + return; + } const url = URL.createObjectURL(blob); if (slot === 'front') this.existing[kind].previewUrl = url; else this.existing[kind].previewUrlBack = url; }, - error: () => undefined + // The row exists but its file is gone from the content store. Saying so on the card beats + // showing "Already available" next to a blank space. + error: () => { + this.existing[kind].previewUnavailable = true; + } }); } @@ -452,6 +548,18 @@ export class CkycTabComponent implements OnInit, OnDestroy { ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => { if (!result) return; + + // The dialog's `accept=` filter is only a hint to the file picker and is trivially bypassed, + // so the size/type guard runs here on what actually came back. Catching it now turns a raw + // server rejection into a message on the card. + const rejection = + this.validationError(result.front, kind) ?? (result.back ? this.validationError(result.back, kind) : null); + if (rejection) { + this.uploadErrors[kind] = rejection; + return; + } + this.uploadErrors[kind] = null; + if (isProofKind) { const proofKind = kind as ProofKind; this.selectedProofType[proofKind] = result.proofType || null; @@ -470,6 +578,66 @@ export class CkycTabComponent implements OnInit, OnDestroy { }); } + /** Extensions a given card can both store and render. */ + private allowedExtensions(kind: CkycDocKind): string[] { + return IMAGE_ONLY_KINDS.includes(kind) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS; + } + + /** + * Rejects a file the backend would refuse (or the card could not render) before it is uploaded, + * so the user gets a clear message instead of a raw server error. Returns null when the file is + * acceptable. + */ + private validationError(file: File | null | undefined, kind: CkycDocKind): string | null { + if (!file) { + return null; + } + const allowed = this.allowedExtensions(kind); + const extension = (file.name.split('.').pop() || '').toLowerCase(); + + if (!allowed.includes(extension)) { + return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`; + } + if (file.size > MAX_UPLOAD_BYTES) { + const mb = (file.size / (1024 * 1024)).toFixed(1); + return `File is ${mb} MB. The maximum allowed size is 5 MB.`; + } + if (file.size === 0) { + return 'The selected file is empty.'; + } + return null; + } + + /** + * True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls + * back to the "View document" button alone rather than rendering a broken image. + */ + isImage(kind: CkycDocKind): boolean { + const entry = this.existing[kind]; + // The profile image arrives as a `data:image/...;base64,` URL, which declares its own type - + // it has neither a stored MIME type nor a file extension for the checks below to read. + if (entry?.previewUrl?.startsWith('data:')) { + return entry.previewUrl.startsWith('data:image/'); + } + if (entry?.mimeType) { + return entry.mimeType.startsWith('image/'); + } + // Older rows can have no stored MIME type - fall back to the file extension. + const extension = (entry?.name?.split('.').pop() || '').toLowerCase(); + return IMAGE_EXTENSIONS.includes(extension); + } + + /** + * Opens the uploaded document full size in a new tab. Uses the blob already fetched for the + * thumbnail, so it works for PDFs too and costs no extra request. + */ + openDocument(kind: CkycDocKind): void { + const url = this.existing[kind]?.previewUrl; + if (url) { + window.open(url, '_blank', 'noopener'); + } + } + /** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and * client-india-address-step, for address proof) writes into the identifier's description. */ private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } { @@ -533,6 +701,10 @@ export class CkycTabComponent implements OnInit, OnDestroy { request$.pipe(takeUntil(this.destroy$)).subscribe({ next: () => { this.uploading[documentType] = false; + // The card is gated on `present`; without this a successful upload left it reading + // "Not uploaded yet". uploadProofDocument() sets the same flag for the proof cards. + this.existing[key].present = true; + this.existing[key].source = this.existing[key].source || 'CKYC upload'; this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' }); this.refreshPreview(documentType); }, @@ -653,13 +825,22 @@ export class CkycTabComponent implements OnInit, OnDestroy { if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) { URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!); } + // The upload that triggered this refresh is what repairs a previously missing file, so the + // warning has to be cleared before the re-read - otherwise a fixed card keeps the old badge. + this.existing[staleKey].previewUrl = undefined; + this.existing[staleKey].previewUrlBack = undefined; + this.existing[staleKey].previewUnavailable = false; switch (documentType) { case 'CLIENT_IMAGE': this.clientsService .getClientProfileImage(this.clientId) .pipe(takeUntil(this.destroy$)) .subscribe((photo: any) => { - if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo; + if (photo) { + this.existing.CLIENT_IMAGE.previewUrl = photo; + } else { + this.existing.CLIENT_IMAGE.previewUnavailable = true; + } }); break; case 'CLIENT_SIGNATURE': @@ -672,11 +853,21 @@ export class CkycTabComponent implements OnInit, OnDestroy { ?.sort((a: any, b: any) => b.id - a.id)?.[0]; if (!doc) return; this.existing.CLIENT_SIGNATURE.documentId = doc.id; + this.existing.CLIENT_SIGNATURE.mimeType = doc.type; this.clientsService - .getClientSignatureImage(this.clientId, doc.id) + .downloadClientDocument(this.clientId, String(doc.id), true) .pipe(takeUntil(this.destroy$)) - .subscribe((blob: any) => { - if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); + .subscribe({ + next: (blob: any) => { + if (blob && blob.size > 0) { + this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); + } else { + this.existing.CLIENT_SIGNATURE.previewUnavailable = true; + } + }, + error: () => { + this.existing.CLIENT_SIGNATURE.previewUnavailable = true; + } }); }); break; @@ -690,10 +881,6 @@ export class CkycTabComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); - // Release object URLs - Object.values(this.existing).forEach((e) => { - if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl); - if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack); - }); + this.revokePreviewUrls(); } } diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.html b/src/app/clients/clients-view/form121-tab/form121-tab.component.html index 1b75a798d..1a351078d 100644 --- a/src/app/clients/clients-view/form121-tab/form121-tab.component.html +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.html @@ -55,7 +55,10 @@
info_outline
- @if (!dateOfBirth) { + @if (configMissing) { + TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under Organization + › TDS Configuration. + } @else if (!dateOfBirth) { Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal Data tab. } @else { @@ -113,8 +116,8 @@
warning_amber
- A Form 121 declaration is on record for this year, but withholding is still switched on for one or more - deposits. Turn it off on those accounts, or tax will keep being deducted. + A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts + deduct on their own and ignore the declaration — remove the tax group from them.
} @@ -189,20 +192,90 @@ }
- @if (declarationOnRecord && declarationFy) { + @if (declarationRecord; as d) {
Declared for - FY {{ declarationFy }} + FY {{ d.fy }} · Form {{ d.formType }}
- } - @if (declarationOnRecord && !acceptanceDateKnown) { -
- Acceptance date +
+ Accepted on - Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change - the figures below. + {{ d.acceptedOn | date: 'd MMM y' }} — no TDS on interest credited from this day to 31 March. Interest + credited before it is taxed as usual.
+ @if (d.uin) { +
+ UIN + {{ d.uin }} +
+ } +
+ + + + +
+ } @else if (fy && !configMissing) { +
+ + + @if (!showDeclarationForm) { + + @if (!panWellFormed) { +
A valid PAN must be on the KYC first — a declaration without one is void.
+ } + } +
+
+ @if (showDeclarationForm) { +
+ + + + @if (declarationError) { +
+ error_outline +
{{ declarationError }}
+
+ } +
+ + +
+
+ } + } + @for (old of declarations; track old.id) { + @if (old.revoked && old.fy === fy) { +
+ Withdrawn + + Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn + {{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }} + +
+ } } @if (eligibility) { @for (reason of eligibility.reasons; track reason) { diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.scss b/src/app/clients/clients-view/form121-tab/form121-tab.component.scss index 4f3666cf1..4b2aa7020 100644 --- a/src/app/clients/clients-view/form121-tab/form121-tab.component.scss +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.scss @@ -332,3 +332,61 @@ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 600; } + +/* ── Recording a declaration ── */ +.f121__btn { + padding: 6px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + &--primary { + border-color: #1d4ed8; + background: #1d4ed8; + color: #fff; + } + + &--warn { + border-color: #b45309; + color: #b45309; + } +} + +.f121__decl-form { + display: grid; + gap: 10px; + padding: 12px 16px; + + label { + display: grid; + gap: 4px; + font-size: 12px; + + span { + color: #6b7280; + font-weight: 600; + } + + input { + max-width: 280px; + padding: 6px 8px; + border: 1px solid #d1d5db; + border-radius: 6px; + font: inherit; + } + } +} + +.f121__decl-actions { + display: flex; + gap: 8px; +} diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.ts b/src/app/clients/clients-view/form121-tab/form121-tab.component.ts index e8813db15..eb5078526 100644 --- a/src/app/clients/clients-view/form121-tab/form121-tab.component.ts +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.ts @@ -23,6 +23,8 @@ import { SavingsService } from 'app/savings/savings.service'; import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { TdsDeclarationRecord, TdsReportsService } from 'app/tds-reports/tds-reports.service'; +import { TdsDeductee } from 'app/tds-reports/tds-reports-data'; /** Domain logic */ import { @@ -111,6 +113,7 @@ export class Form121TabComponent implements OnInit, OnDestroy { private savingsService = inject(SavingsService); private fixedDepositsService = inject(FixedDepositsService); private recurringDepositsService = inject(RecurringDepositsService); + private tdsService = inject(TdsReportsService); private destroy$ = new Subject(); @@ -124,23 +127,25 @@ export class Form121TabComponent implements OnInit, OnDestroy { pan = ''; panWellFormed = false; - // ── Declaration on record ── - declarationOnRecord = false; + // ── Declarations on record ── + /** + * Every declaration the customer has given, each for one year and from the day the branch + * accepted it. The KYC record only ever held a type and a year, which made a September + * declaration shield interest back to April and let last year's form run on. + */ + declarations: TdsDeclarationRecord[] = []; /** * The form actually in force for the selected year. From FY 2026-27 a single * Form 121 replaced the 15G/15H split, so this is derived from the year rather * than from the payee's age. */ declarationForm: DeclarationForm = '121'; - /** Kind recorded at KYC, meaningful only for a pre-2026 year. */ - declarationKind: '15G' | '15H' = '15G'; - declarationFy = ''; - /** - * The KYC step records only the year, not the date the branch accepted the - * declaration, so it has to be treated as covering the whole year. Surfaced on - * screen rather than hidden — a mid-year acceptance would change the figures. - */ - acceptanceDateKnown = false; + + // ── Recording a declaration ── + showDeclarationForm = false; + savingDeclaration = false; + declarationError = ''; + newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null }; eligibility: EligibilityResult | null = null; @@ -153,11 +158,13 @@ export class Form121TabComponent implements OnInit, OnDestroy { schedule: TdsSchedule | null = null; actualTds = 0; tdsSaved = 0; + /** The selected year has no entry on Organization › TDS Configuration. */ + configMissing = false; /** Raw account inputs, kept so the year selector can recompute cheaply. */ - private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = []; - /** Withheld-tax postings by FY, from the ledger. */ - private actualTdsByFy = new Map(); + private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = []; + /** This customer's FD/RD interest received in savings, and TDS taken there, per year. */ + private platformByFy = new Map(); ngOnInit(): void { this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => { @@ -180,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[]); }), @@ -210,11 +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 ? declarationFormFor(this.fy, this.dateOfBirth) : '121'; - this.declarationFy = kyc?.form15FY ?? ''; - this.acceptanceDateKnown = false; + this.declarationForm = + this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121'; } /** Savings, FD and RD accounts — everything that can credit interest. */ @@ -248,7 +254,7 @@ export class Form121TabComponent implements OnInit, OnDestroy { */ private ingest(details: any[]): void { this.accountInputs = []; - this.actualTdsByFy = new Map(); + this.platformByFy = new Map(); const fys = new Set(); const clientIdNum = Number(this.clientId); @@ -269,6 +275,7 @@ export class Form121TabComponent implements OnInit, OnDestroy { }; const credits: InterestCredit[] = []; + const taxLines: InterestCredit[] = []; let tdsDeducted = 0; for (const t of d.transactions ?? []) { @@ -286,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 }); } } @@ -310,12 +316,13 @@ 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] ?? ''; } } @@ -333,14 +340,37 @@ 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) { + if (!this.dateOfBirth || this.configMissing) { return; } @@ -378,21 +408,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 ───────────────────────── @@ -411,7 +515,7 @@ export class Form121TabComponent implements OnInit, OnDestroy { /** The form the payee would file for the selected year. */ get formLabel(): string { - if (!this.fy) { + if (!this.fy || this.configMissing) { return '—'; } const form = declarationFormFor(this.fy, this.dateOfBirth); @@ -470,11 +574,12 @@ export class Form121TabComponent implements OnInit, OnDestroy { } /** - * A declaration is on record but deposit accounts are still set to withhold — - * the branch has not switched them off. + * A declaration is on record but a deposit still carries a stock Fineract tax group. Those + * accounts withhold on their own rules and never consult the declaration record, so the form + * does not stop their deduction until the tax group is removed. */ get declarationNotAppliedToAccounts(): boolean { - return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax); + return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup); } /** diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index 44b97a309..98a1c34c5 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -1247,9 +1247,7 @@
@if (!docScansLoading && !docScans.front && !docScans.back) { -
- No scan is on file for this document. Upload one below. -
+
No scan is on file for this document. Upload one below.
}
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index 84743bb5b..611abcf88 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -182,7 +182,10 @@ export class KycTabComponent implements OnInit, OnDestroy { docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null }; /** The two sides, in display order, so the template loops rather than repeating itself. */ - readonly docScanSlots: ('front' | 'back')[] = ['front', 'back']; + readonly docScanSlots: ('front' | 'back')[] = [ + 'front', + 'back' + ]; docScanOf(slot: 'front' | 'back') { return this.docScans[slot]; @@ -474,7 +477,8 @@ export class KycTabComponent implements OnInit, OnDestroy { adverseMediaMatch: !!v.adverseMediaMatch, // Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a // customer who declared no income at all. - declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome, + declaredAnnualIncome: + v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome, declaredAnnualTurnover: v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover }; @@ -519,7 +523,7 @@ export class KycTabComponent implements OnInit, OnDestroy { dateFormat: 'dd MMMM yyyy', ...this.buildDocumentFields(), ...this.buildRiskProfileFields(), - ...this.buildExceptionFields() + ...this.buildExceptionFields() }; if (!this.kycId) { @@ -766,7 +770,7 @@ export class KycTabComponent implements OnInit, OnDestroy { riskCategory: this.kycForm.value.riskCategory || 1, ...declarationFields, ...this.buildRiskProfileFields(), - ...this.buildExceptionFields() + ...this.buildExceptionFields() }; this.clientsService @@ -927,7 +931,7 @@ export class KycTabComponent implements OnInit, OnDestroy { riskCategory: this.kycForm.value.riskCategory || 1, ...receiptFields, ...this.buildRiskProfileFields(), - ...this.buildExceptionFields() + ...this.buildExceptionFields() }; this.clientsService @@ -1455,7 +1459,10 @@ I further confirm that: this.docScansLoading = false; // This runs again after every replace, so the previews being thrown away are revoked // rather than left holding their blobs. - for (const old of [this.docScans.front, this.docScans.back]) { + for (const old of [ + this.docScans.front, + this.docScans.back + ]) { if (old?.previewUrl) { URL.revokeObjectURL(old.previewUrl); } @@ -1497,7 +1504,10 @@ I further confirm that: } this.docScans = sides; - for (const key of ['front', 'back'] as const) { + for (const key of [ + 'front', + 'back' + ] as const) { if (sides[key]) { this.loadScanPreview(sides[key]!); } @@ -1558,7 +1568,9 @@ I further confirm that: error: (err: any) => { this.docScanUploading = null; this.docScanError = - err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`; + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + `Could not replace the ${label.toLowerCase()} side.`; } }); } @@ -1741,7 +1753,10 @@ I further confirm that: this.destroy$.next(); this.destroy$.complete(); // Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab. - for (const side of [this.docScans.front, this.docScans.back]) { + for (const side of [ + this.docScans.front, + this.docScans.back + ]) { if (side?.previewUrl) { URL.revokeObjectURL(side.previewUrl); } diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 7220fbe47..cd08181ce 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -569,8 +569,19 @@ export class ClientsService { return this.http.get(`/clients/${clientId}/documents`); } - downloadClientDocument(parentEntityId: string, documentId: string) { - return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' }); + /** + * Fetches a client document's stored file. + * + * `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller + * renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file + * is no longer in the content store returns 500 and would otherwise pop a modal the user can do + * nothing about. The interceptor still rethrows, so the caller's error callback continues to run. + */ + downloadClientDocument(parentEntityId: string, documentId: string, silent = false) { + return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { + responseType: 'blob', + ...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}) + }); } uploadClientDocument(clientId: string, documentData: any) { @@ -992,12 +1003,28 @@ export class ClientsService { return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data); } - getClientCkyc(clientId: string): Observable { - return this.http.get(`/clients/${clientId}/ckyc`); + /** + * Uploads a single CKYC artefact against the client, using the platform's generic document + * endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no + * CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path + * was never implemented and returned 404. + * + * The multipart body must carry `file`, `name` and `description`; the caller builds those so the + * stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts. + */ + uploadCkycDocument(clientId: string, formData: FormData): Observable { + return this.http.post(`/clients/${clientId}/documents`, formData); } - uploadCkycDocument(clientId: string, formData: FormData): Observable { - return this.http.post(`/clients/${clientId}/ckyc/documents`, formData); + /** + * Replaces the content of an existing client document in place (DocumentApiResource PUT + * /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id. + * + * Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact + * rather than adding another row. Takes the same multipart body as the create call. + */ + replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable { + return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData); } /** diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html index 6e646f72a..bf720407f 100644 --- a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html @@ -109,13 +109,7 @@ }
- +
}
diff --git a/src/app/core/http/cache.interceptor.ts b/src/app/core/http/cache.interceptor.ts index b20947383..d20aca013 100644 --- a/src/app/core/http/cache.interceptor.ts +++ b/src/app/core/http/cache.interceptor.ts @@ -25,6 +25,36 @@ import { environment } from '../../../environments/environment'; export class CacheInterceptor implements HttpInterceptor { private forceUpdate = false; + /** + * Endpoints that compute a live figure from the current ledger/configuration and must never be + * served from cache. Caching them meant a report kept showing a stale response after its + * underlying GL mapping changed — the request was answered from cachedData and the backend was + * never hit again. Matched as a substring of the request URL. + */ + private static readonly NEVER_CACHE = [ + '/rbireservereturn', + '/rbireserveconfig', + '/tdsconfig', + '/tds/deposit-interest', + // Declarations and challans are entered on the very screens that read them back. + '/tds/declarations', + '/tds/challans', + '/tds/deducted', + '/savingsinterestpostingreports', + // Cash in vaults and drawers changes with every transaction; a cached teller list or position + // kept showing a branch's old tellers and figures at day close. + '/tellers', + '/cashreports', + '/cashreconciliation', + '/cashsettlement', + '/cashtransfer', + '/branchday' + ]; + + private isCacheable(url: string): boolean { + return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment)); + } + // eslint-disable-next-line @angular-eslint/prefer-inject constructor(private httpCacheService: HttpCacheService) {} @@ -42,7 +72,7 @@ export class CacheInterceptor implements HttpInterceptor { } intercept(request: HttpRequest, next: HttpHandler): Observable> { - if (request.method !== 'GET') { + if (request.method !== 'GET' || !this.isCacheable(request.urlWithParams)) { return next.handle(request); } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 3351e3022..27da072b6 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -65,6 +65,14 @@ ESC Show Header } + + @else if (currentUrl.includes('/fd-dashboard')) { + + } @else if (currentUrl.includes('/clerk/')) {
@@ -64,6 +64,25 @@
+ + @if (branchName || ifscCode || micrCode) { +
+ @if (branchName) { + {{ branchName }} Branch + } + @if (branchAddress) { + {{ branchAddress }} + } + @if (ifscCode) { + IFSC: {{ ifscCode }} + } + @if (micrCode) { + MICR: {{ micrCode }} + } +
+ } +
diff --git a/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.scss b/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.scss index 647d16d12..50d73a7f2 100644 --- a/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.scss +++ b/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.scss @@ -127,6 +127,18 @@ $header-text: #fff; } } +// ─── Branch Identification Line ────────────────────────────────────────────── +.branch-line { + background: #e8edf5; + border-bottom: 1px solid $border-color; + padding: 6px 24px; + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + font-size: 11px; + color: #4a5a7a; +} + // ─── Account Details Band ──────────────────────────────────────────────────── .account-details-band { background: #f0f3f9; diff --git a/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts b/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts index 697d53a5c..2c04ece28 100644 --- a/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts +++ b/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts @@ -11,11 +11,12 @@ import { Component, OnInit, OnDestroy, inject, ElementRef, ViewChild } from '@an import { ActivatedRoute } from '@angular/router'; import { UntypedFormBuilder } from '@angular/forms'; import { CurrencyPipe } from '@angular/common'; -import { Subject, takeUntil } from 'rxjs'; +import { Subject, takeUntil, catchError, forkJoin, of } from 'rxjs'; /** Custom Services */ import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; +import { OrganizationService } from '../../../organization/organization.service'; import { Currency } from 'app/shared/models/general.model'; import { CurrentAccountTransaction, @@ -44,6 +45,7 @@ export class PassbookTabComponent implements OnInit, OnDestroy { private formBuilder = inject(UntypedFormBuilder); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); + private organizationService = inject(OrganizationService); private destroy$ = new Subject(); @@ -60,6 +62,17 @@ export class PassbookTabComponent implements OnInit, OnDestroy { availableBalance: number = 0; activatedOnDate: any = null; + /** Bank/branch letterhead — real values from Institution Profile + Branch Profile (Organization + * module) rather than a hardcoded name, so a printed passbook actually identifies which bank and + * branch issued it, the way a real UCB's core banking system does. Falls back to a generic label + * if the profile was never filled in, rather than showing a fabricated bank name. */ + bankName = 'Bank'; + bankSubtitle = 'Current Account Statement'; + branchName = ''; + branchAddress = ''; + ifscCode = ''; + micrCode = ''; + /** Transactions */ allTransactions: CurrentAccountTransaction[] = []; filteredTransactions: CurrentAccountTransaction[] = []; @@ -92,6 +105,33 @@ export class PassbookTabComponent implements OnInit, OnDestroy { fromDate: [''], toDate: [''] }); + + this.loadLetterhead(); + } + + /** Institution Profile is the bank's own single record; Branch Profile is per-office. There is no + * office id on the current account data reaching this tab, and this deployment runs a single + * branch, so the first branch profile on record is the correct one here — a genuinely + * multi-branch deployment would need the account's own office id threaded through instead. */ + private loadLetterhead(): void { + forkJoin({ + institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))), + branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([]))) + }).subscribe(({ institution, branches }: any) => { + if (institution) { + this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank'; + this.bankSubtitle = institution.licenseNumber + ? `Lic. No. ${institution.licenseNumber}` + : 'Current Account Statement'; + } + const branch = Array.isArray(branches) ? branches[0] : null; + if (branch) { + this.branchName = branch.officeName || ''; + this.branchAddress = branch.address || ''; + this.ifscCode = branch.ifscCode || ''; + this.micrCode = branch.micrCode || ''; + } + }); } private computeRunningBalances(transactions: CurrentAccountTransaction[]): CurrentAccountTransaction[] { @@ -279,6 +319,18 @@ export class PassbookTabComponent implements OnInit, OnDestroy { margin-top: 3px; } + /* Branch identification line */ + .branch-line { + background: #e8edf5; + border-bottom: 1px solid #d0d7e3; + padding: 5px 20px; + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + font-size: 9.5px; + color: #4a5a7a; + } + /* Account details band */ .account-details-band { background: #f0f3f9; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts index a8ebfa8de..666f126d5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts @@ -334,17 +334,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); if (this.principalAmount && this.principalAmount > 0) { - // Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0 - // months, which the backend rejects -- send it as tenureInDays instead in that case. + // Years and Months convert exactly; a Days tenure is sent as days and never converted. let tenureInMonths: number | undefined; let tenureInDays: number | undefined; if (this.tenurePeriodUnit === 'Years') { tenureInMonths = this.tenureValue * 12; } else if (this.tenurePeriodUnit === 'Months') { tenureInMonths = this.tenureValue; - } else if (enteredDays >= 30) { - tenureInMonths = Math.floor(enteredDays / 30); } else { + // Sent as days, whatever the count. Converting to whole months silently dropped the + // remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the + // screen still said "200 Days" above the figure. The backend takes days directly and + // works the calendar out itself. tenureInDays = enteredDays; } @@ -423,10 +424,17 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan } else { const n = 12 / compMonths; const effectiveMonths = tenureInMonths ?? enteredDays / 30; - const totalCompoundings = Math.floor(effectiveMonths / compMonths); const rPerCompounding = rate / (n * 100); - result.maturityAmount = - Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100; + // Whole compounding periods, then simple interest on the days left over. Flooring the + // periods and stopping there dropped the remainder entirely: a 200-day deposit + // compounding monthly quoted only six months of interest and lost the other twenty + // days, which is the depositor's money. + const wholePeriods = Math.floor(effectiveMonths / compMonths); + const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods); + const leftoverDays = enteredDays - wholePeriods * compMonths * 30; + const leftoverInterest = + leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0; + result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100; } result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100; this.matchResult = { ...result }; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html index 17d6f00d7..bb548478e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html @@ -7,17 +7,29 @@ -->
-

{{ 'labels.heading.For Pre-mature closure' | translate }}

+

{{ 'labels.heading.Maturity Instructions' | translate }}

{{ 'labels.inputs.Maturity Instructions' | translate }} + -- Not selected (decide later via FD Dashboard) -- @for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) { {{ maturityInstruction.value | translateKey: 'catalogs' }} } + @if (closureSettingsForm.value.maturityInstructionId) { + + } @if (closureSettingsForm.contains('transferToSavingsId')) { @@ -28,8 +40,8 @@ savings
-

{{ 'labels.inputs.Transfer to savings account' | translate }}

- Maturity proceeds will be credited to this account +

{{ transferAccountCardTitle }}

+ {{ transferAccountCardSubtitle }}
@@ -69,7 +81,7 @@ } @else { - {{ 'labels.inputs.Transfer to savings account' | translate }} + {{ transferAccountCardTitle }} @for (account of savingsAccountsData; track account) { @@ -77,13 +89,15 @@ } - Transfer to savings account is required + {{ transferAccountCardTitle }} is required } } +

{{ 'labels.heading.For Pre-mature closure' | translate }}

+ {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts index bce8468a1..d4552f9d1 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts @@ -11,6 +11,7 @@ import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/cor import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatIcon } from '@angular/material/icon'; +import { MatIconButton } from '@angular/material/button'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, MatCheckbox, - MatIcon + MatIcon, + MatIconButton ] }) export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges { @@ -53,9 +55,12 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges preClosurePenalInterestOnTypeId: [''] }); this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => { - // 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both - // Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too. - if (value === 200) { + // 200 = Transfer to Savings -- the whole matured balance goes here. 400 = Reinvest Principal + // Only -- the principal rolls into a new FD, but the leftover interest still needs the same + // kind of target so it isn't left stranded on the closed account (a real gap the account used + // to silently hit before this field also asked for it here). Reinvest Principal + Interest + // (300) rolls the whole balance into the same account, so it never needs a target at all. + if (value === 200 || value === 400) { this.closureSettingsForm.addControl( 'transferToSavingsId', new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required) @@ -91,15 +96,48 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges }); } } + // Editing an existing account: its own already-saved Maturity Instructions choice lives on + // `fixedDepositsAccountTemplate` (the real account+template GET, FixedDepositAccountData's + // `onAccountClosure`) -- NOT on `fixedDepositsAccountProductTemplate`, which is only the blank + // per-product default template refetched from the Product dropdown's own productId (via + // getFixedDepositsAccountTemplate(clientId, productId)) and carries no account-specific data at + // all. This was never read from the right place before, so Edit always opened onto a blank + // picker even for an account that really does have an instruction saved. `.dirty` guards it the + // same way the penal-interest fields above do, so a maker's own in-progress edit here is never + // silently overwritten if this input fires again mid-edit. + if (this.fixedDepositsAccountTemplate && !this.closureSettingsForm.get('maturityInstructionId').dirty) { + this.closureSettingsForm + .get('maturityInstructionId') + ?.setValue(this.fixedDepositsAccountTemplate.onAccountClosure?.id ?? ''); + const transferControl = this.closureSettingsForm.get('transferToSavingsId'); + if (transferControl) { + transferControl.setValue( + this.fixedDepositsAccountTemplate.transferToSavingsId ?? this.linkedSavingsAccountId ?? '' + ); + } + } } - /** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */ + /** The linked-savings account for the transfer-to-savings / reinvest-principal-only-interest + * maturity instruction, once picked. */ get selectedTransferAccount(): any { const id = this.closureSettingsForm.value.transferToSavingsId; if (!id) return null; return this.savingsAccountsData.find((account: any) => account.id === id) ?? null; } + get transferAccountCardTitle(): string { + return this.closureSettingsForm.value.maturityInstructionId === 400 + ? 'Interest payout account' + : 'Transfer to savings account'; + } + + get transferAccountCardSubtitle(): string { + return this.closureSettingsForm.value.maturityInstructionId === 400 + ? 'The principal renews into a new FD; interest earned is paid out to this account.' + : 'Maturity proceeds will be credited to this account.'; + } + clearTransferAccount(): void { this.closureSettingsForm.get('transferToSavingsId')?.setValue(''); } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html index 659f79528..94cd1ea54 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -8,11 +8,24 @@
- -
-

{{ 'labels.heading.Payment' | translate }}

-

Process this Fixed Deposit now -- works whether it's Active or Matured.

+
+ +
+

+ {{ isDashboardRenewalMode ? 'Renew Fixed Deposit' : ('labels.heading.Payment' | translate) }} +

+

+ {{ + isDashboardRenewalMode + ? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.' + : 'Process this matured Fixed Deposit now.' + }} +

+
+
@@ -27,37 +40,92 @@
} @else { + @if (isMaturedRenewal) { +

+ + {{ maturedRenewalTimingText }} +

+ + @if (renewalPreviewLoading) { +

+ + Calculating gap-period interest... +

+ } @else if (renewalPreview && !renewalPreview.withinGrace) { + @if (!renewalPreview.linkedAccountId) { + + } @else { +
+
+ FD Maturity Balance (renews as-is below) + {{ renewalPreview.currentBalance | formatNumber }} +
+
+ Gap Days ({{ renewalPreview.maturityDate }} → today) + {{ renewalPreview.gapDays }} +
+
+ Deposit Account Link + {{ renewalPreview.linkedAccountNo }} +
+
+ Its Interest Rate + {{ renewalPreview.linkedAccountRate }}% +
+
+ Gap-Period Interest → paid to {{ renewalPreview.linkedAccountNo }} + + {{ renewalPreview.gapInterestAmount | formatNumber }} +
+
+

+ + This gap interest was earned at the linked account's own rate, not the FD's -- it is credited straight + to account {{ renewalPreview.linkedAccountNo }} (the FD's Deposit Account Link), separately from + whatever you choose below. The FD itself renews from its own maturity balance, unchanged. +

+ } + } + } + @if (reinvestCap !== null && reinvestCap !== undefined) {
{{ - remainderIsApproximate - ? 'Amount available on the selected date' - : ('labels.inputs.Maturity Amount' | translate) + isMaturedRenewal || !remainderIsApproximate + ? ('labels.inputs.Maturity Amount' | translate) + : 'Amount available on the selected date' }} {{ reinvestCap | formatNumber }}
} - - {{ 'labels.inputs.Closed On Date' | translate }} - - - - @if (paymentForm.controls.closedOnDate.hasError('required')) { - - {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - + @if (!isMaturedRenewal) { + + {{ 'labels.inputs.Closed On Date' | translate }} + + + + @if (paymentForm.controls.closedOnDate.hasError('required')) { + + {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + }
@@ -162,10 +230,12 @@ } - - {{ 'labels.inputs.Note' | translate }} - - + @if (!isMaturedRenewal) { + + {{ 'labels.inputs.Note' | translate }} + + + } @if (submitError) {
- +
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss index 48967437c..c6c8920ad 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss @@ -6,291 +6,466 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$navy: #0d1b2a; -$slate: #475569; -$muted: #64748b; -$border: #e2e8f0; -$soft: #f8fafc; -$blue: #1e40af; -$blue-soft: #eff6ff; -$green: #166534; -$green-soft: #f0fdf4; +/** + * Payment / Renewal Dialog Styles + * CBSNexora Core Banking System + */ .pmt { display: flex; flex-direction: column; - min-height: 320px; - max-height: 85vh; + height: 100%; + max-height: 92vh; + box-sizing: border-box; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: #0f172a; + border-radius: 16px; overflow: hidden; - margin: -24px; + box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%); } .pmt__header { - display: flex; - align-items: flex-start; - gap: 14px; - padding: 22px 28px 18px; - background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%); - color: #fff; - flex: 0 0 auto; -} - -.pmt__header-ico { - width: 40px; - height: 40px; - min-width: 40px; - border-radius: 10px; - background: rgb(255 255 255 / 15%); display: flex; align-items: center; - justify-content: center; - font-size: 18px; + justify-content: space-between; + gap: 16px; + padding: 26px 30px; + background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 55%, #2563eb 100%); + color: #fff; + flex-shrink: 0; + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%); + pointer-events: none; + } + + &-left { + display: flex; + align-items: center; + gap: 16px; + position: relative; + z-index: 1; + } + + &-ico { + width: 48px; + height: 48px; + border-radius: 14px; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 25%); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: #fff; + flex-shrink: 0; + } + + &-text { + display: flex; + flex-direction: column; + } + + &-close { + position: relative; + z-index: 1; + background: rgb(255 255 255 / 14%); + border: none; + color: #fff; + width: 34px; + height: 34px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + + &:hover:not(:disabled) { + background: rgb(255 255 255 / 28%); + transform: rotate(90deg); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } } .pmt__title { margin: 0; - font-size: 19px; - font-weight: 700; + font-size: 20px; + font-weight: 800; + color: #fff; + line-height: 1.25; + letter-spacing: -0.01em; } .pmt__subtitle { - margin: 4px 0 0; - font-size: 12.5px; - color: rgb(255 255 255 / 82%); + margin: 5px 0 0; + font-size: 13px; + color: rgb(255 255 255 / 88%); line-height: 1.4; } .pmt__content { - padding: 22px 28px 8px !important; + padding: 26px 30px !important; overflow-y: auto; flex: 1 1 auto; + background: #fbfcfe; + + mat-form-field { + width: 100%; + } } .pmt__loading { display: flex; justify-content: center; align-items: center; - min-height: 260px; + min-height: 240px; } .pmt__stat { display: flex; flex-direction: column; - gap: 2px; - background: $blue-soft; - border: 1px solid #dbeafe; - border-radius: 10px; - padding: 12px 16px; + gap: 4px; + background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%); + border: 1px solid #bfdbfe; + border-radius: 14px; + padding: 18px 22px; margin-bottom: 18px; + + &-label { + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #1d4ed8; + } + + &-value { + font-family: 'JetBrains Mono', 'DM Sans', monospace; + font-size: 28px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.01em; + } } -.pmt__stat-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: $blue; -} +.pmt__preview { + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 4px 18px; + margin: 0 0 18px; -.pmt__stat-value { - font-size: 22px; - font-weight: 800; - color: $navy; - font-variant-numeric: tabular-nums; -} + &-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 0; + border-bottom: 1px solid #f1f5f9; -.pmt__date { - width: 100%; + &:last-child { + border-bottom: none; + } + + &--highlight { + .pmt__preview-value { + color: #0d9488; + font-weight: 800; + } + } + + &--total { + margin: 0 -18px; + padding: 14px 18px; + background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%); + border-bottom: none; + border-radius: 0 0 12px 12px; + + .pmt__preview-label { + color: #1d4ed8; + font-weight: 700; + } + + .pmt__preview-value { + color: #0f172a; + font-size: 18px; + font-weight: 800; + } + } + } + + &-label { + font-size: 12.5px; + color: #64748b; + font-weight: 600; + } + + &-value { + font-family: 'JetBrains Mono', 'DM Sans', monospace; + font-size: 14px; + font-weight: 700; + color: #0f172a; + } } .pmt__section-label { - font-size: 11.5px; + font-size: 12px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.05em; - color: $slate; - margin: 4px 0 10px; + letter-spacing: 0.06em; + color: #475569; + margin: 10px 0 12px; } +/* Action Options Grid - 2 Equal Columns, comfortably wide now that the dialog itself is wider */ .pmt__options { display: grid; - grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); - gap: 10px; - margin-bottom: 6px; + grid-template-columns: repeat(2, 1fr); + gap: 14px; + margin-bottom: 18px; + + @media (width <= 560px) { + grid-template-columns: 1fr; + } } .pmt__option { display: flex; align-items: flex-start; - gap: 10px; + gap: 14px; text-align: left; background: #fff; - border: 1.5px solid $border; - border-radius: 10px; - padding: 12px 14px; + border: 1.5px solid #e2e8f0; + border-radius: 14px; + padding: 16px; cursor: pointer; font: inherit; - transition: - border-color 0.15s, - background 0.15s, - box-shadow 0.15s; -} + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; -.pmt__option:hover { - border-color: #93c5fd; - background: $blue-soft; -} + &:hover { + border-color: #93c5fd; + background: #f8fafc; + box-shadow: 0 6px 16px rgb(15 23 42 / 6%); + transform: translateY(-1px); + } -.pmt__option--active { - border-color: $blue; - background: $blue-soft; - box-shadow: 0 0 0 1px $blue; -} + &--active { + border-color: #2563eb; + background: #eff6ff; + box-shadow: 0 0 0 1.5px #2563eb; -.pmt__option-ico { - width: 30px; - height: 30px; - min-width: 30px; - border-radius: 8px; - background: $soft; - color: $blue; - display: flex; - align-items: center; - justify-content: center; - font-size: 13px; -} + .pmt__option-ico { + background: #2563eb; + color: #fff; + border-color: #2563eb; + } + } -.pmt__option--active .pmt__option-ico { - background: $blue; - color: #fff; -} + &-ico { + width: 38px; + height: 38px; + min-width: 38px; + border-radius: 10px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + color: #2563eb; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + flex-shrink: 0; + transition: all 0.2s ease; + } -.pmt__option-text { - display: flex; - flex-direction: column; - gap: 2px; -} + // Distinct accent per option while unselected, purely presentational -- helps the four cards read + // apart at a glance. Selecting a card always fills its icon blue (see &--active above), matching the + // blue selection border/check regardless of which option it is, rather than mixing selection-blue + // with the option's own accent. + &:not(.pmt__option--active):nth-child(2) .pmt__option-ico { + color: #7c3aed; + } -.pmt__option-title { - font-size: 13px; - font-weight: 700; - color: $navy; -} + &:not(.pmt__option--active):nth-child(3) .pmt__option-ico { + color: #0d9488; + } -.pmt__option-desc { - font-size: 11px; - color: $muted; - line-height: 1.35; -} + &:not(.pmt__option--active):nth-child(4) .pmt__option-ico { + color: #d97706; + } -.pmt__option:focus-visible { - outline: 2px solid $blue; - outline-offset: 2px; -} + &-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + } -.pmt__option-check { - margin-left: auto; - color: $blue; - font-size: 13px; + &-title { + font-size: 14px; + font-weight: 700; + color: #0f172a; + line-height: 1.3; + } + + &-desc { + font-size: 12px; + color: #64748b; + line-height: 1.4; + } + + &-check { + color: #2563eb; + font-size: 17px; + position: absolute; + top: 14px; + right: 14px; + } } .pmt__option-error { color: #b91c1c; font-size: 12px; - margin: 4px 0 14px; -} - -.pmt__error { - display: flex; - align-items: flex-start; - gap: 4px; - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 8px; - color: #b91c1c; - font-size: 12.5px; - line-height: 1.45; - padding: 10px 14px; - margin: 4px 0 8px; -} - -.pmt__hint { - display: flex; - align-items: flex-start; - gap: 4px; - background: $soft; - border: 1px solid $border; - border-radius: 8px; - color: $slate; - font-size: 12px; - line-height: 1.45; - padding: 9px 13px; - margin: 0 0 10px; -} - -.pmt__hint--warn { - background: #fffbeb; - border-color: #fde68a; - color: #92400e; + font-weight: 600; + margin: -10px 0 14px; } .pmt__split { display: flex; align-items: center; - gap: 12px; - background: $soft; + gap: 16px; + background: #f8fafc; border: 1px dashed #cbd5e1; - border-radius: 10px; - padding: 14px 16px 0; - margin: 4px 0 8px; + border-radius: 14px; + padding: 18px; + margin: 10px 0 18px; flex-wrap: wrap; + + &-input { + flex: 1 1 220px; + } + + &-arrow { + color: #94a3b8; + font-size: 17px; + } + + &-remainder { + display: flex; + flex-direction: column; + gap: 3px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 10px; + padding: 12px 16px; + flex: 1 1 220px; + + &-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #166534; + } + + &-value { + font-family: 'JetBrains Mono', 'DM Sans', monospace; + font-size: 19px; + font-weight: 800; + color: #166534; + } + } } -.pmt__split-input { - flex: 1 1 180px; - min-width: 160px; -} - -.pmt__split-arrow { - color: $muted; - font-size: 14px; - margin-bottom: 20px; -} - -.pmt__split-remainder { +.pmt__hint { display: flex; - flex-direction: column; - gap: 2px; - background: $green-soft; - border: 1px solid #bbf7d0; - border-radius: 8px; - padding: 8px 14px; - margin-bottom: 14px; - flex: 1 1 180px; - min-width: 160px; + align-items: flex-start; + gap: 10px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-left: 4px solid #0284c7; + border-radius: 10px; + color: #0369a1; + font-size: 13px; + line-height: 1.5; + padding: 12px 16px; + margin: 0 0 18px; + + &--warn { + background: #fffbeb; + border-color: #fde68a; + border-left-color: #d97706; + color: #b45309; + } } -.pmt__split-remainder-label { - font-size: 10.5px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - color: $green; -} - -.pmt__split-remainder-value { - font-size: 16px; - font-weight: 800; - color: $green; - font-variant-numeric: tabular-nums; -} - -.pmt__content mat-form-field { - width: 100%; +.pmt__error { + display: flex; + align-items: flex-start; + gap: 10px; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #ef4444; + border-radius: 10px; + color: #b91c1c; + font-size: 13px; + padding: 12px 16px; + margin: 10px 0; } .pmt__actions { - padding: 14px 28px 20px !important; - border-top: 1px solid $border; - flex: 0 0 auto; + padding: 18px 30px !important; + border-top: 1px solid #e2e8f0; + background: #fff; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + flex-shrink: 0; + + .pmt__btn-cancel { + border-radius: 10px; + font-weight: 600; + color: #475569; + } + + .pmt__btn-submit { + border-radius: 10px; + font-weight: 700; + padding: 0 26px; + height: 42px; + background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%); + color: #fff; + box-shadow: 0 8px 20px rgb(37 99 235 / 30%); + transition: all 0.2s ease; + + &:hover:not(:disabled) { + box-shadow: 0 10px 24px rgb(37 99 235 / 40%); + transform: translateY(-1px); + } + + &:disabled { + background: #cbd5e1; + box-shadow: none; + color: #64748b; + } + } } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts index cd433f83c..521656fa8 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts @@ -24,15 +24,26 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn, /** Custom Services */ import { FixedDepositsService } from '../../../fixed-deposits.service'; import { SettingsService } from 'app/settings/settings.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { Dates } from 'app/core/utils/dates'; import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FdMaturityRow } from 'app/fd-dashboard/fd-dashboard.compute'; /** * "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and * Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice, * Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the * entered amount renews on this same account, the rest transfers to a savings account picked here. + * + * Doubles as the FD Dashboard's Renew action when opened with `data.dashboardRenewal` set: same form, + * same full set of closure types (nothing removed -- Transfer to Savings and Partial Reinvest work from + * the Dashboard too, not just the two Reinvest options). The only two differences in that mode: the + * Closed On Date is computed from the FD's grace window instead of picked freely (backdated to the + * original maturity date within grace, today once it's passed -- the picker is hidden, not just + * defaulted, so it can't accidentally be moved off that date), and submit() first calls + * `creditGapInterestIfPastGrace` when past grace, crediting the gap-period interest (at the linked + * account's rate) onto the FD before the normal `payment` command runs and reads its balance. */ @Component({ selector: 'mifosx-payment-dialog', @@ -76,11 +87,18 @@ export class PaymentDialogComponent implements OnInit { }; dialogRef = inject>(MatDialogRef); - data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA); + data: { + accountId: number | string; + accountBalance?: number; + maturityDate?: any; + /** Present only when opened from the FD Dashboard's Renew action. */ + dashboardRenewal?: { row: FdMaturityRow }; + } = inject(MAT_DIALOG_DATA); private formBuilder = inject(UntypedFormBuilder); private fixedDepositsService = inject(FixedDepositsService); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); + private alertService = inject(AlertService); paymentForm: UntypedFormGroup; /** Loaded once, on open, from the same closure template the existing Close action already uses. */ @@ -93,6 +111,16 @@ export class PaymentDialogComponent implements OnInit { submitError: string = null; maxDate: Date; + /** Whether gap-interest still needs crediting before this submits -- only ever true once, right + * after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */ + private gapInterestPending = false; + + /** Read-only numbers (current balance, linked account's rate, gap days, gap-interest amount, + * projected new balance) fetched once on open in dashboard-renewal mode, shown above the Action + * cards so staff see exactly what a late renewal is about to credit before confirming anything. */ + renewalPreview: any = null; + renewalPreviewLoading = false; + ngOnInit(): void { this.maxDate = this.settingsService.businessDate; this.paymentForm = this.formBuilder.group({ @@ -106,6 +134,34 @@ export class PaymentDialogComponent implements OnInit { ], note: [''] }); + + // Fetched every time this dialog opens, not just from the FD Dashboard -- Payment is now only ever + // offered once an FD has Matured (see FixedDepositsButtonsConfiguration), so a plain Payment on it + // is exactly the same "late renewal" scenario the Dashboard's Renew action handles, and needs the + // exact same date-locking/gap-interest treatment, or an FD closed this way instead of from the + // Dashboard would silently skip crediting interest it genuinely earned. + this.renewalPreviewLoading = true; + this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({ + next: (preview: any) => { + this.renewalPreview = preview; + this.renewalPreviewLoading = false; + if (preview?.isMatured) { + // Computed from the grace window, never picked -- the date field stays in the form (the + // generic submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its + // picker is hidden once matured (see the template), so it can't be moved off this value. + this.paymentForm + .get('closedOnDate') + .setValue(preview.withinGrace ? new Date(preview.maturityDate) : this.maxDate); + this.gapInterestPending = !preview.withinGrace; + } + }, + // Non-fatal -- Confirm still works from here (backend re-derives the same numbers on submit), + // only the numeric preview and its date-locking are unavailable, not the action itself. + error: () => { + this.renewalPreviewLoading = false; + } + }); + this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); // The cap depends on whether the chosen date is on or after maturity, so a date change can make an // already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date. @@ -136,6 +192,31 @@ export class PaymentDialogComponent implements OnInit { }); } + get isDashboardRenewalMode(): boolean { + return !!this.data.dashboardRenewal; + } + + get dashboardRenewalRow(): FdMaturityRow | null { + return this.data.dashboardRenewal?.row ?? null; + } + + /** True once the loaded preview confirms this FD has actually Matured -- which, since Payment is + * only ever offered on a Matured account, is every real open of this dialog now, from either entry + * point. Drives the date-locking and gap-interest preview/crediting identically either way. */ + get isMaturedRenewal(): boolean { + return !!this.renewalPreview?.isMatured; + } + + /** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date once + * matured, since the date itself is no longer visible to explain it. */ + get maturedRenewalTimingText(): string { + if (!this.renewalPreview) return ''; + return this.renewalPreview.withinGrace + ? `Backdated to its original maturity date (${this.renewalPreview.maturityDate}), at the currently active interest rate.` + : `This FD's grace period has passed -- gap-period interest (for the days since maturity) will be ` + + `credited at the linked account's rate first, then this renews from today.`; + } + /** * The amount being split, and the bound the entered partial amount is checked against. *

@@ -259,6 +340,31 @@ export class PaymentDialogComponent implements OnInit { } submit(): void { + if (this.gapInterestPending) { + this.submitting = true; + this.submitError = null; + this.fixedDepositsService.creditGapInterestIfPastGrace(this.data.accountId).subscribe({ + next: () => { + this.gapInterestPending = false; + this.submitGenericPayment(); + }, + error: (error: any) => { + this.submitting = false; + this.submitError = + error?.error?.defaultUserMessage ?? + error?.error?.errors?.[0]?.defaultUserMessage ?? + 'Could not credit gap-period interest. Please review the details and try again.'; + } + }); + return; + } + this.submitGenericPayment(); + } + + /** The same `command=payment` submission every non-Dashboard call to this dialog already used -- + * Dashboard-renewal mode reaches it too, once any gap-interest pre-step above has cleared, so every + * closure type behaves identically regardless of where the dialog was opened from. */ + private submitGenericPayment(): void { const formValue = { ...this.paymentForm.value }; const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; @@ -271,7 +377,16 @@ export class PaymentDialogComponent implements OnInit { this.fixedDepositsService .executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data) .subscribe({ - next: () => { + next: (response: any) => { + // Parked in the Checker Inbox rather than executed -- see the same handling in + // PrematureCloseDialogComponent for why an HTTP 200 here can still mean "not applied yet". + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: + 'This payment has been submitted for approval. The deposit is unchanged until a checker approves it.' + }); + } this.dialogRef.close({ confirm: true }); }, // Keep the dialog open with the entered values and say what went wrong, rather than leaving a diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html new file mode 100644 index 000000000..cc6736b60 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html @@ -0,0 +1,282 @@ + + +

+
+
+ +
+

{{ 'labels.heading.Premature Close' | translate }}

+

+ Close {{ data.accountNo ?? 'this account' }} before maturity -- interest is recalculated as of the date below, + usually at a lower, penalty rate. +

+
+
+ +
+ +
+ @if (loadFailed) { + + } @else { + + + {{ 'labels.inputs.Premature Close Date' | translate }} + + + + @if (prematureCloseForm.controls.closedOnDate.hasError('required')) { + + {{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + Closure Reason + + @for (reason of closureReasonOptions; track reason.id) { + {{ reason.label }} + } + + + + @if (loadingAmount) { +
+ + Calculating premature-closure amount... +
+ } @else { + @if (data.activatedOnDate || data.maturityDate || data.currentBalance !== undefined) { +
+ @if (data.activatedOnDate) { +
+ Current Term Started + {{ activatedOnDateDisplay }} +
+
+ Tenure Completed (this term) + {{ tenureElapsedDisplay }} ({{ daysElapsed }} days) +
+ } + @if (data.maturityDate) { +
+ Scheduled Maturity Date + {{ maturityDateDisplay }} + ({{ daysRemainingToMaturity }} days left) +
+ } + @if (data.currentBalance !== undefined && data.currentBalance !== null) { +
+ Current Balance (as of today) + {{ data.currentBalance | formatNumber }} +
+ } +
+ } + + @if (maturityAmount !== null && maturityAmount !== undefined) { +
+ @if (data.depositAmount) { +
+ Deposit Amount + {{ data.depositAmount | formatNumber }} +
+ } + @if (data.nominalAnnualInterestRate) { +
+ Contracted Interest Rate + {{ data.nominalAnnualInterestRate }}% +
+ } + @if (effectiveInterestRate !== null && effectiveInterestRate !== undefined) { +
+ Effective Rate for this closure + {{ effectiveInterestRate }}% +
+ } + @if (data.preClosurePenalApplicable) { +
+ Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }}) + + @if (prematureCloseForm.get('waivePreClosurePenalty').value) { + Waived + } @else { + − {{ data.preClosurePenalInterest }}% + } + +
+ } + @if (interestEarned !== null) { +
+ Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }}) + {{ interestEarned >= 0 ? '+' : '' }} {{ interestEarned | formatNumber }} +
+ } +
+ {{ 'labels.inputs.Maturity Amount' | translate }} + {{ maturityAmount | formatNumber }} +
+
+ } + + @if (data.preClosurePenalApplicable) { + @if (isAutoWaivedReason) { +
+ + + Penalty automatically waived -- {{ selectedReasonLabel }} always waives the + pre-closure penalty, regardless of this checkbox. + +
+ } @else { + + Waive the pre-closure penalty for this closure + + 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. + + + } + } + + +
+ @for (closureOption of onAccountClosureOptions; track closureOption) { + + } +
+ @if ( + prematureCloseForm.controls.onAccountClosureId?.hasError('required') && + prematureCloseForm.controls.onAccountClosureId?.touched + ) { +
+ {{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} +
+ } + + @if (noDestinationAccounts) { + + } + + @if (prematureCloseForm.contains('toSavingsAccountId')) { + + {{ 'labels.inputs.Transfer to Savings' | translate }} + + @for (account of savingsAccountsData; track account) { + + {{ account.accountNo }} + + } + + @if (prematureCloseForm.controls.toSavingsAccountId?.hasError('required')) { + + {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + } + + @if (prematureCloseForm.contains('toGlAccountId')) { + + + } + + @if (prematureCloseForm.contains('transferDescription')) { + + {{ 'labels.inputs.Transfer Description' | translate }} + + + } + + @if (prematureCloseForm.contains('note')) { + + {{ 'labels.inputs.Note' | translate }} + + + } + } + + @if (submitError) { + + } + + } +
+ +
+ + +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss new file mode 100644 index 000000000..ad41732d2 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss @@ -0,0 +1,468 @@ +/** + * 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/. + */ + +/** + * Premature Close Dialog Styles + * CBSNexora Core Banking System + * + * Same shape/spacing/typography as the Payment dialog (.pmt__*), in this Danger Zone action's own red + * accent on the header and Confirm button, rather than Payment's blue -- an irreversible, usually + * penalty-bearing action reads differently from a routine one, everywhere else stays identical so the + * two dialogs feel like the same product. + */ + +.pcd { + display: flex; + flex-direction: column; + height: 100%; + max-height: 92vh; + box-sizing: border-box; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: #0f172a; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%); +} + +.pcd__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 26px 30px; + background: linear-gradient(135deg, #7f1d1d 0%, #b91c1c 55%, #dc2626 100%); + color: #fff; + flex-shrink: 0; + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%); + pointer-events: none; + } + + &-left { + display: flex; + align-items: center; + gap: 16px; + position: relative; + z-index: 1; + } + + &-ico { + width: 48px; + height: 48px; + border-radius: 14px; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 25%); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: #fff; + flex-shrink: 0; + } + + &-text { + display: flex; + flex-direction: column; + } + + &-close { + position: relative; + z-index: 1; + background: rgb(255 255 255 / 14%); + border: none; + color: #fff; + width: 34px; + height: 34px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + + &:hover:not(:disabled) { + background: rgb(255 255 255 / 28%); + transform: rotate(90deg); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.pcd__title { + margin: 0; + font-size: 20px; + font-weight: 800; + color: #fff; + line-height: 1.25; + letter-spacing: -0.01em; +} + +.pcd__subtitle { + margin: 5px 0 0; + font-size: 13px; + color: rgb(255 255 255 / 88%); + line-height: 1.4; +} + +.pcd__content { + padding: 26px 30px !important; + overflow-y: auto; + flex: 1 1 auto; + background: #fbfcfe; + + mat-form-field { + width: 100%; + } +} + +.pcd__loading { + display: flex; + align-items: center; + gap: 12px; + color: #64748b; + font-size: 13px; + padding: 14px 0 18px; +} + +.pcd__preview { + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 4px 18px; + margin: 0 0 18px; + + &-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 0; + border-bottom: 1px solid #f1f5f9; + + &:last-child { + border-bottom: none; + } + + &--highlight { + .pcd__preview-value { + color: #b91c1c; + font-weight: 800; + } + } + + &--total { + margin: 0 -18px; + padding: 14px 18px; + background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); + border-bottom: none; + border-radius: 0 0 12px 12px; + + .pcd__preview-label { + color: #b91c1c; + font-weight: 700; + } + + .pcd__preview-value { + color: #0f172a; + font-size: 18px; + font-weight: 800; + } + } + } + + &-label { + font-size: 12.5px; + color: #64748b; + font-weight: 600; + } + + &-value { + font-family: 'JetBrains Mono', 'DM Sans', monospace; + font-size: 14px; + font-weight: 700; + color: #0f172a; + } + + &-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 { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #475569; + margin: 10px 0 12px; +} + +/* Action Options Grid - 2 Equal Columns */ +.pcd__options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; + margin-bottom: 18px; + + @media (width <= 560px) { + grid-template-columns: 1fr; + } +} + +.pcd__option { + display: flex; + align-items: flex-start; + gap: 14px; + text-align: left; + background: #fff; + border: 1.5px solid #e2e8f0; + border-radius: 14px; + padding: 16px; + cursor: pointer; + font: inherit; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + + &:hover { + border-color: #fca5a5; + background: #f8fafc; + box-shadow: 0 6px 16px rgb(15 23 42 / 6%); + transform: translateY(-1px); + } + + &--active { + border-color: #dc2626; + background: #fef2f2; + box-shadow: 0 0 0 1.5px #dc2626; + + .pcd__option-ico { + background: #dc2626; + color: #fff; + border-color: #dc2626; + } + } + + &-ico { + width: 38px; + height: 38px; + min-width: 38px; + border-radius: 10px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + color: #dc2626; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + flex-shrink: 0; + transition: all 0.2s ease; + } + + &-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + } + + &-title { + font-size: 14px; + font-weight: 700; + color: #0f172a; + line-height: 1.3; + } + + &-desc { + font-size: 12px; + color: #64748b; + line-height: 1.4; + } + + &-check { + color: #dc2626; + font-size: 17px; + position: absolute; + top: 14px; + right: 14px; + } +} + +.pcd__option-error { + color: #b91c1c; + font-size: 12px; + font-weight: 600; + margin: -10px 0 14px; +} + +.pcd__hint { + display: flex; + align-items: flex-start; + gap: 10px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-left: 4px solid #0284c7; + border-radius: 10px; + color: #0369a1; + font-size: 13px; + line-height: 1.5; + padding: 12px 16px; + margin: 0 0 18px; + + &--warn { + background: #fffbeb; + border-color: #fde68a; + border-left-color: #d97706; + color: #b45309; + } +} + +.pcd__error { + display: flex; + align-items: flex-start; + gap: 10px; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #ef4444; + border-radius: 10px; + color: #b91c1c; + font-size: 13px; + padding: 12px 16px; + margin: 10px 0; +} + +.pcd__actions { + padding: 18px 30px !important; + border-top: 1px solid #e2e8f0; + background: #fff; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + flex-shrink: 0; + + .pcd__btn-cancel { + border-radius: 10px; + font-weight: 600; + color: #475569; + } + + .pcd__btn-submit { + border-radius: 10px; + font-weight: 700; + padding: 0 26px; + height: 42px; + background: linear-gradient(135deg, #b91c1c 0%, #dc2626 100%); + color: #fff; + box-shadow: 0 8px 20px rgb(220 38 38 / 30%); + transition: all 0.2s ease; + + &:hover:not(:disabled) { + box-shadow: 0 10px 24px rgb(220 38 38 / 40%); + transform: translateY(-1px); + } + + &:disabled { + background: #cbd5e1; + box-shadow: none; + color: #64748b; + } + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts new file mode 100644 index 000000000..f24a480c5 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts @@ -0,0 +1,446 @@ +/** + * 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/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms'; + +/** 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): 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. + *

+ * The premature amount is date-dependent (unlike Payment's one-shot template load): every change to + * Closed On Date re-calls `calculatePrematureAmount`, same as the full-page form this replaces did. + */ +@Component({ + selector: 'mifosx-premature-close-dialog', + templateUrl: './premature-close-dialog.component.html', + styleUrls: ['./premature-close-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose, + CdkTextareaAutosize, + FaIconComponent, + FormatNumberPipe, + GlAccountSelectorComponent + ] +}) +export class PrematureCloseDialogComponent implements OnInit { + /** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */ + private static readonly WITHDRAW_DEPOSIT_ID = 100; + private static readonly TRANSFER_TO_SAVINGS_ID = 200; + private static readonly TRANSFER_TO_GL_ID = 600; + + private static readonly ACTION_ICONS: Record = { + [PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave', + [PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet', + [PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'book' + }; + + private static readonly ACTION_DESCRIPTIONS: Record = { + [PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash', + [PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account', + [PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'Post the full balance to a GL account' + }; + + /** + * Mirrors the backend's PrematureClosureReasonType values exactly -- see that enum for why these + * four (everything but Normal) always waive the penalty by RBI policy rather than at the maker's + * discretion. `gatedBy` is the product-facility flag (from `data`) that must be true for the option + * to even be offered; `null` means always offered. Deceased Settlement / Nominee-Legal Heir Closure + * are gated because they are the product's own opt-in facilities (see SavingsProduct + * deceasedSettlement/noPenaltyNomineeHeirClosure) -- a product that never enabled one shouldn't let + * a maker pick it to bypass a penalty that product's terms don't actually exempt. + */ + private static readonly NORMAL_REASON_ID = 100; + private static readonly ALL_CLOSURE_REASONS: { + id: number; + label: string; + autoWaived: boolean; + gatedBy: 'deceasedSettlement' | 'noPenaltyNomineeHeirClosure' | null; + }[] = [ + { id: 100, label: 'Normal Premature Closure', autoWaived: false, gatedBy: null }, + { id: 200, label: 'Premature Extension / Renewal', autoWaived: true, gatedBy: null }, + { id: 300, label: 'Conversion to Another Term Deposit', autoWaived: true, gatedBy: null }, + { id: 400, label: 'Deceased Settlement', autoWaived: true, gatedBy: 'deceasedSettlement' }, + { id: 500, label: 'Nominee / Legal Heir Closure', autoWaived: true, gatedBy: 'noPenaltyNomineeHeirClosure' } + ]; + + dialogRef = inject>(MatDialogRef); + data: { + accountId: number | string; + accountNo?: string; + depositAmount?: number; + nominalAnnualInterestRate?: number; + preClosurePenalApplicable?: boolean; + preClosurePenalInterest?: number; + preClosurePenalInterestOnType?: { value: string }; + deceasedSettlement?: boolean; + noPenaltyNomineeHeirClosure?: boolean; + activatedOnDate?: any; + maturityDate?: any; + currentBalance?: number; + } = inject(MAT_DIALOG_DATA); + private formBuilder = inject(UntypedFormBuilder); + private fixedDepositsService = inject(FixedDepositsService); + private settingsService = inject(SettingsService); + private accountingService = inject(AccountingService); + private alertService = inject(AlertService); + private dateUtils = inject(Dates); + + prematureCloseForm: UntypedFormGroup; + onAccountClosureOptions: any[] = []; + savingsAccountsData: any[] = []; + /** Postable GL accounts, loaded once -- only read when the GL action is chosen. */ + glAccountsData: GLAccount[] = []; + maturityAmount: number = null; + /** The rate actually resolved for THIS preview by the backend (actual-period vs contracted, + * penalty-deducted or not) -- distinct from data.nominalAnnualInterestRate, which is always the + * account's unmodified contracted rate. Read fresh on every recalculation. */ + effectiveInterestRate: number = null; + loadingAmount = true; + loadFailed = false; + submitting = false; + submitError: string = null; + minDate = new Date(2000, 0, 1); + maxDate: Date; + + ngOnInit(): void { + this.maxDate = this.settingsService.businessDate; + this.prematureCloseForm = this.formBuilder.group({ + closedOnDate: [ + this.maxDate, + Validators.required + ], + // Both always present (not added conditionally like onAccountClosureId below) so submit() + // always carries a value, even for a product with no penalty configured at all -- the backend + // ignores them there anyway, since there is nothing to waive. + prematureClosureReasonId: [PrematureCloseDialogComponent.NORMAL_REASON_ID], + waivePreClosurePenalty: [false] + }); + + this.prematureCloseForm + .get('closedOnDate') + .valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date)); + // Waiving/un-waiving recalculates the preview the same way changing the date does -- the maker + // should see the actual amount they're about to confirm, not the penalty-applied one, the moment + // they tick the box. + this.prematureCloseForm + .get('waivePreClosurePenalty') + .valueChanges.subscribe(() => this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value)); + // Picking an RBI-mandated waiver reason forces the checkbox on (see onClosureReasonChange) and, + // like it, recalculates the preview so the amount shown always matches the reason selected. + this.prematureCloseForm + .get('prematureClosureReasonId') + .valueChanges.subscribe((id: number) => this.onClosureReasonChange(id)); + // Same date the form already starts with -- without this the amount/options never load unless the + // maker happens to touch the date picker, which the full-page form this replaces required too. + this.calculatePrematureAmount(this.maxDate); + } + + /** The reason list this account's product actually allows -- see ALL_CLOSURE_REASONS' `gatedBy`. */ + get closureReasonOptions(): { id: number; label: string; autoWaived: boolean }[] { + return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.filter( + (reason) => !reason.gatedBy || this.data[reason.gatedBy] + ); + } + + /** True when the selected reason waives the penalty by policy, not maker choice -- the checkbox is + * hidden in favour of a plain statement of that fact when this is true (see the template). */ + get isAutoWaivedReason(): boolean { + return !!this.selectedReason?.autoWaived; + } + + get selectedReasonLabel(): string { + return this.selectedReason?.label ?? ''; + } + + private get selectedReason(): { id: number; label: string; autoWaived: boolean } | undefined { + const id = this.prematureCloseForm?.get('prematureClosureReasonId')?.value; + return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((reason) => reason.id === id); + } + + private onClosureReasonChange(reasonId: number): void { + const reason = PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((r) => r.id === reasonId); + if (reason?.autoWaived) { + // emitEvent: false -- the explicit calculatePrematureAmount call below already covers this + // change; without it, this setValue would also fire waivePreClosurePenalty's own subscription + // and the amount would be calculated twice for one reason change. + this.prematureCloseForm.get('waivePreClosurePenalty').setValue(true, { emitEvent: false }); + } + this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value); + } + + private calculatePrematureAmount(date: Date): void { + this.loadingAmount = true; + this.loadFailed = false; + const locale = this.settingsService.language.code; + const dateFormat = this.settingsService.dateFormat; + const data = { + closedOnDate: this.dateUtils.formatDate(date, dateFormat), + dateFormat, + locale, + prematureClosureReasonId: this.prematureCloseForm.get('prematureClosureReasonId')?.value, + waivePreClosurePenalty: !!this.prematureCloseForm.get('waivePreClosurePenalty')?.value + }; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data) + .subscribe({ + next: (response: any) => { + this.savingsAccountsData = response.savingsAccounts ?? []; + this.onAccountClosureOptions = response.onAccountClosureOptions ?? []; + this.maturityAmount = response.maturityAmount; + this.effectiveInterestRate = response.nominalAnnualInterestRate ?? null; + this.loadingAmount = false; + if (!this.prematureCloseForm.contains('onAccountClosureId')) { + this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required)); + this.prematureCloseForm.addControl('note', new UntypedFormControl('')); + this.prematureCloseForm + .get('onAccountClosureId') + .valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); + } + }, + // Without this the spinner would run forever on a failed load, leaving Cancel as the only way out. + error: () => { + this.loadingAmount = false; + this.loadFailed = true; + } + }); + } + + /** Adds/removes the destination control to match what the chosen type needs. */ + private onClosureTypeChange(id: number): void { + const toSavings = id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID; + const toGl = id === PrematureCloseDialogComponent.TRANSFER_TO_GL_ID; + + if (toSavings && !this.prematureCloseForm.contains('toSavingsAccountId')) { + this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required)); + } else if (!toSavings) { + this.prematureCloseForm.removeControl('toSavingsAccountId'); + } + + if (toGl) { + if (!this.prematureCloseForm.contains('toGlAccountId')) { + this.prematureCloseForm.addControl('toGlAccountId', new UntypedFormControl('', Validators.required)); + } + this.loadGlAccounts(); + } else { + this.prematureCloseForm.removeControl('toGlAccountId'); + } + + // Both destinations carry the description through -- to the account transfer, or to the narration on + // the journal entry that credits the GL account. + if ((toSavings || toGl) && !this.prematureCloseForm.contains('transferDescription')) { + this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl('')); + } else if (!toSavings && !toGl) { + this.prematureCloseForm.removeControl('transferDescription'); + } + } + + /** Fetched on first use rather than with the dialog, since most closures never pick the GL action. */ + private loadGlAccounts(): void { + if (this.glAccountsData.length) { + return; + } + this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => { + this.glAccountsData = accounts ?? []; + }); + } + + /** The GL selector takes a control instance, not a control name. */ + get toGlAccountControl(): UntypedFormControl { + return this.prematureCloseForm.get('toGlAccountId') as UntypedFormControl; + } + + /** The gap between the deposit and the computed maturity amount -- the actual interest this premature + * closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */ + get interestEarned(): number | null { + if (this.maturityAmount === null || this.maturityAmount === undefined || !this.data.depositAmount) { + return null; + } + return Number(this.maturityAmount) - Number(this.data.depositAmount); + } + + get activatedOnDateDisplay(): string { + return this.formatFullDate(this.data.activatedOnDate); + } + + get maturityDateDisplay(): string { + return this.formatFullDate(this.data.maturityDate); + } + + /** + * The date every "as of" figure in this dialog is measured against -- the maker's own chosen Premature + * Close Date, falling back to Fineract's configured Business Date. Deliberately NOT the real-world + * clock (`new Date()`): Fineract's business date can lag behind the calendar (a bank's own admin + * advances it), and the backend's interest calculation is measured from THIS date, not today's actual + * date. Using the real clock here previously showed a "days elapsed" figure that disagreed with the + * interest the backend actually computed for the same closure -- both must read off the same clock. + */ + private get referenceDate(): Date { + const picked = this.prematureCloseForm?.get('closedOnDate')?.value; + return picked instanceof Date ? picked : this.maxDate; + } + + /** Days from the current term's start to the chosen closure date -- 0 if the date is missing or the + * closure date is somehow before it, rather than a negative. */ + get daysElapsed(): number | null { + const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null; + if (!start) { + return null; + } + return Math.max(0, this.dateUtils.calculateDiff(start, this.referenceDate)); + } + + /** Days from the chosen closure date to the scheduled maturity date -- 0 once matured/overdue rather + * than negative, since this dialog is for a still-Active account closing early, not a matured one. */ + get daysRemainingToMaturity(): number | null { + const maturity = this.data.maturityDate ? this.dateUtils.parseDate(this.data.maturityDate) : null; + if (!maturity) { + return null; + } + return Math.max(0, this.dateUtils.calculateDiff(this.referenceDate, maturity)); + } + + /** Elapsed tenure as Years/Months/Days, the way a real passbook states it, rather than a raw day + * count that is hard to read for a multi-year FD. */ + get tenureElapsedDisplay(): string { + const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null; + if (!start) { + return '—'; + } + const { years, months, days } = PrematureCloseDialogComponent.yearsMonthsDaysBetween(start, this.referenceDate); + const parts: string[] = []; + if (years > 0) { + parts.push(`${years} yr${years === 1 ? '' : 's'}`); + } + if (months > 0) { + parts.push(`${months} mo${months === 1 ? '' : 's'}`); + } + if (days > 0 || parts.length === 0) { + parts.push(`${days} day${days === 1 ? '' : 's'}`); + } + return parts.join(', '); + } + + private formatFullDate(value: any): string { + if (!value) { + return '—'; + } + const parsed = this.dateUtils.parseDate(value); + // "28 September 2026" -- day/month-name/year, so this reads unambiguously regardless of the + // account's own configured short date format elsewhere in the app. + return this.dateUtils.formatDate(parsed, 'd MMMM y'); + } + + private static yearsMonthsDaysBetween(start: Date, end: Date): { years: number; months: number; days: number } { + let years = end.getFullYear() - start.getFullYear(); + let months = end.getMonth() - start.getMonth(); + let days = end.getDate() - start.getDate(); + if (days < 0) { + months -= 1; + const daysInPreviousMonth = new Date(end.getFullYear(), end.getMonth(), 0).getDate(); + days += daysInPreviousMonth; + } + if (months < 0) { + years -= 1; + months += 12; + } + return { years: Math.max(0, years), months: Math.max(0, months), days: Math.max(0, days) }; + } + + /** True when the chosen action needs a destination account but the customer has none to pick. */ + get noDestinationAccounts(): boolean { + return ( + !this.loadingAmount && + this.prematureCloseForm?.contains('toSavingsAccountId') && + this.savingsAccountsData.length === 0 + ); + } + + iconFor(id: number): string { + return PrematureCloseDialogComponent.ACTION_ICONS[id] ?? 'circle'; + } + + descriptionFor(id: number): string { + return PrematureCloseDialogComponent.ACTION_DESCRIPTIONS[id] ?? ''; + } + + selectClosureType(id: number): void { + this.prematureCloseForm.get('onAccountClosureId').setValue(id); + this.prematureCloseForm.get('onAccountClosureId').markAsTouched(); + } + + submit(): void { + const formValue = { ...this.prematureCloseForm.value }; + const locale = this.settingsService.language.code; + const dateFormat = this.settingsService.dateFormat; + if (formValue.closedOnDate instanceof Date) { + formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat); + } + const data = { ...formValue, dateFormat, locale }; + this.submitting = true; + this.submitError = null; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data) + .subscribe({ + next: (response: any) => { + // A command on a maker-checker permission is parked in the Checker Inbox, not executed: + // the platform rolls its transaction back and still answers HTTP 200 with + // rollbackTransaction: true. Without saying so, the dialog closes and the account reloads + // unchanged -- which reads as "nothing happened" rather than "waiting for approval". + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: + 'Premature closure has been submitted for approval. The deposit is unchanged until a checker approves it.' + }); + } + this.dialogRef.close({ confirm: true }); + }, + // Keep the dialog open with the entered values and say what went wrong, rather than leaving a + // permanently disabled Confirm behind whatever modal the global interceptor threw up. + error: (error: any) => { + this.submitting = false; + this.submitError = + error?.error?.defaultUserMessage ?? + error?.error?.errors?.[0]?.defaultUserMessage ?? + 'This account could not be prematurely closed. Please review the details and try again.'; + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts index fefdb323d..088e31ec2 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts @@ -17,6 +17,7 @@ import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-int import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component'; import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component'; import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component'; +import { PrematureCloseDialogComponent } from './custom-dialogs/premature-close-dialog/premature-close-dialog.component'; import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component'; /** Custom Button Config. */ @@ -29,6 +30,7 @@ import { AccountsService } from 'app/accounts/accounts.service'; import { Currency } from 'app/shared/models/general.model'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; +import { AlertService } from 'app/core/alert/alert.service'; import { AuthenticationService } from '../../../core/authentication/authentication.service'; import { MatTooltip } from '@angular/material/tooltip'; import { NgClass, CurrencyPipe } from '@angular/common'; @@ -73,6 +75,7 @@ import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dor export class FixedDepositAccountViewComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); + private alertService = inject(AlertService); private fixedDepositsService = inject(FixedDepositsService); private savingsService = inject(SavingsService); private accountsService = inject(AccountsService); @@ -238,7 +241,6 @@ export class FixedDepositAccountViewComponent implements OnInit { case 'Undo Activation': case 'Add Charge': case 'Withdrawn by Client': - case 'Premature Close': case 'Deposit': case 'Withdrawal': this.router.navigate([`actions/${name}`], { relativeTo: this.route }); @@ -267,16 +269,21 @@ export class FixedDepositAccountViewComponent implements OnInit { case 'Payment': this.openPaymentDialog(); break; + case 'Premature Close': + this.openPrematureCloseDialog(); + break; } } /** - * Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or - * Matured, and is the only place a Partial Reinvest can be done. + * Opens the Payment action -- only offered once the FD has actually Matured (see + * FixedDepositsButtonsConfiguration), and the only place a Partial Reinvest can be done. Also the + * exact same dialog the FD Dashboard's Renew action opens (see fd-dashboard.component.ts's + * openRenewDialog), just without dashboardRenewal data, so its date picker stays visible. */ private openPaymentDialog(): void { const paymentDialogRef = this.dialog.open(PaymentDialogComponent, { - width: '640px', + width: '720px', maxWidth: '95vw', data: { accountId: this.fixedDepositsAccountData.id, @@ -293,6 +300,60 @@ export class FixedDepositAccountViewComponent implements OnInit { }); } + /** + * The start date of the term currently running -- the last entry in renewalHistory, which is ordered + * oldest-first (see RenewalHistoryTabComponent). Falls back to the account's own activation date for + * an FD that has never been renewed (or whose renewalHistory hasn't loaded), which is the same date + * in that case anyway. + */ + private get currentTermStartDate(): any { + const history = this.fixedDepositsAccountData?.renewalHistory; + const latestTerm = Array.isArray(history) && history.length > 0 ? history[history.length - 1] : null; + return latestTerm?.activatedondate ?? this.fixedDepositsAccountData?.timeline?.activatedOnDate; + } + + /** + * Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't + * matured yet. + */ + private openPrematureCloseDialog(): void { + const prematureCloseDialogRef = this.dialog.open(PrematureCloseDialogComponent, { + width: '720px', + maxWidth: '95vw', + data: { + accountId: this.fixedDepositsAccountData.id, + accountNo: this.fixedDepositsAccountData.fdAccountNumber || this.fixedDepositsAccountData.accountNo, + // calculatePrematureAmount only ever returns the final maturityAmount, not how it was reached -- + // these come from the account's own data (already loaded for this page) so the dialog can show + // the breakdown (deposit, contracted rate, penalty, interest earned) behind that one number. + depositAmount: this.fixedDepositsAccountData.depositAmount, + nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate, + preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable, + preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest, + preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType, + // Gate which RBI-mandated waiver reasons the dialog offers -- these two are the product's own + // facility toggles (Deceased Settlement / No Penalty Nominee-Heir Closure), off by default, so + // a product that never enabled them shouldn't offer a reason implying it did. + deceasedSettlement: this.fixedDepositsAccountData.deceasedSettlement, + noPenaltyNomineeHeirClosure: this.fixedDepositsAccountData.noPenaltyNomineeHeirClosure, + // Tenure context (current term's start date, maturity date, current balance) so the dialog can + // show how far into THIS term the account is and how much is actually sitting in it today, not + // just the penalty-adjusted amount calculatePrematureAmount returns. Deliberately the current + // term's own start (see currentTermStartDate), not the account's original activation -- a + // renewed FD's interest/tenure runs from the renewal, and showing the original activation date + // here would make an account renewed days ago look like it has been running for months. + activatedOnDate: this.currentTermStartDate, + maturityDate: this.fixedDepositsAccountData.maturityDate, + currentBalance: this.fixedDepositsAccountData.summary?.accountBalance + } + }); + prematureCloseDialogRef.afterClosed().subscribe((response: any) => { + if (response?.confirm) { + this.reload(); + } + }); + } + /** * Deletes Fixed Deposits Account. */ @@ -360,7 +421,16 @@ export class FixedDepositAccountViewComponent implements OnInit { if (response.confirm) { this.fixedDepositsService .executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {}) - .subscribe(() => { + .subscribe((commandResponse: any) => { + // Parked in the Checker Inbox rather than executed (HTTP 200 with + // rollbackTransaction: true), so the reload below shows no new interest posting. + if (commandResponse?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: + 'Interest posting has been sent for checker approval. No interest is posted until a checker approves it.' + }); + } this.reload(); }); } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts index 3146a730c..9ce40ede6 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts @@ -57,11 +57,12 @@ export class FixedDepositsButtonsConfiguration { ]; break; case 'Matured': + // 'Close' (the generic Fineract apply-maturity-instructions action) is deliberately left out + // here -- what happens to a matured FD is now handled either automatically by the nightly job + // (an account opened with an explicit instruction) or manually via the FD Dashboard's Renew + // action (an account left without one, backdated within grace / gap-interest after). Closing + // from here would go around both of those paths. this.buttonsArray = [ - { - name: 'Close', - icon: 'fa fa-arrow-right' - }, { name: 'Calculate Interest', icon: 'fa fa-table' @@ -109,7 +110,18 @@ export class FixedDepositsButtonsConfiguration { setOptions(status: string) { switch (status) { case 'Active': + this.optionArray = [ + { + name: 'Post Interest' + }, + { + name: 'Add Charge' + } + ]; + break; case 'Matured': + // Payment only makes sense once the deposit has actually matured -- an Active FD that needs + // closing early already has Premature Close for that. this.optionArray = [ { name: 'Post Interest' diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html index 3648e2ee1..885983e11 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html @@ -41,8 +41,8 @@ ) {

- - @if (term.renewalstatus === 'ACTIVE') { + + @if (isTermActive(term, last)) { } @@ -51,15 +51,15 @@ }
-
+

{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}

- - - {{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }} + + + {{ termStatusLabel(term, last) }} @if (renewalTypeLabel(term)) { @@ -95,7 +95,7 @@
{{ - term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned' + isTermActive(term, last) ? 'Interest So Far' : 'Interest Earned' }} {{ @@ -106,9 +106,7 @@
- {{ - term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At' - }} + {{ maturityLabel(term, last) }} {{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts index 34bd1bb2b..b58372aa5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts @@ -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. * diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts index a517f98b5..9ae78708e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts @@ -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 }); + }); + }); } } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component.ts index 002d3a90f..0564d7e2f 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component.ts @@ -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 }); + }); } } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html index f20e46af8..92b1d954b 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html @@ -24,9 +24,6 @@ @if (actions['Add Charge']) { } -@if (actions['Premature Close']) { - -} @if (actions['Close']) { } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts index e0b47a2f7..b587839e8 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts @@ -15,7 +15,6 @@ import { ActivateFixedDepositsAccountComponent } from './activate-fixed-deposits import { UndoApprovalFixedDepositsAccountComponent } from './undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component'; import { WithdrawByClientFixedDepositsAccountComponent } from './withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component'; import { AddChargeFixedDepositsAccountComponent } from './add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component'; -import { PrematureCloseFixedDepositsAccountComponent } from './premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component'; import { CloseFixedDepositsAccountComponent } from './close-fixed-deposits-account/close-fixed-deposits-account.component'; import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -35,7 +34,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; UndoApprovalFixedDepositsAccountComponent, WithdrawByClientFixedDepositsAccountComponent, AddChargeFixedDepositsAccountComponent, - PrematureCloseFixedDepositsAccountComponent, CloseFixedDepositsAccountComponent, FixedDepositsCashTransactionComponent ] @@ -52,7 +50,6 @@ export class FixedDepositsAccountActionsComponent { 'Undo Approval': boolean; 'Undo Activation': boolean; 'Add Charge': boolean; - 'Premature Close': boolean; 'Withdrawn by Client': boolean; Withdrawal: boolean; Deposit: boolean; @@ -64,7 +61,6 @@ export class FixedDepositsAccountActionsComponent { 'Undo Approval': false, 'Undo Activation': false, 'Add Charge': false, - 'Premature Close': false, 'Withdrawn by Client': false, Withdrawal: false, Deposit: false diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.html b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.html deleted file mode 100644 index 0d204538d..000000000 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.html +++ /dev/null @@ -1,105 +0,0 @@ - - -
- -
- -
- - {{ 'labels.inputs.Premature Close Date' | translate }} - - - - @if (prematureCloseAccountForm.controls.closedOnDate.hasError('required')) { - - {{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - @if (prematureCloseAccountForm.controls.maturityAmount) { - - {{ 'labels.inputs.Maturity Amount' | translate }} - - - } - - @if (prematureCloseAccountForm.contains('onAccountClosureId')) { - - {{ 'labels.inputs.Action' | translate }} - - @for (closureOption of onAccountClosureOptions; track closureOption) { - - {{ closureOption.value }} - - } - - @if (prematureCloseAccountForm.controls.onAccountClosureId.hasError('required')) { - - {{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - } - - @if (prematureCloseAccountForm.contains('toSavingsAccountId')) { - - {{ 'labels.inputs.Transfer to Savings' | translate }} - - @for (account of savingsAccountsData; track account) { - - {{ account.accountNo }} - - } - - @if (prematureCloseAccountForm.controls.toSavingsAccountId.hasError('required')) { - - {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - } - - @if (prematureCloseAccountForm.contains('transferDescription')) { - - {{ 'labels.inputs.Transfer Description' | translate }} - - - } - - @if (prematureCloseAccountForm.contains('note')) { - - {{ 'labels.inputs.Note' | translate }} - - - } -
-
- - - - - -
-
-
diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.scss b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.scss deleted file mode 100644 index 7c3f1555b..000000000 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.scss +++ /dev/null @@ -1,11 +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/. - */ - -.container { - max-width: 37rem; -} diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.ts deleted file mode 100644 index 5e4d5dc51..000000000 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.ts +++ /dev/null @@ -1,179 +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/. - */ - -/** Angular Imports */ -import { Component, OnInit, inject } from '@angular/core'; -import { - UntypedFormGroup, - UntypedFormBuilder, - Validators, - UntypedFormControl, - ReactiveFormsModule -} from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; - -/** Custom Services */ -import { FixedDepositsService } from '../../fixed-deposits.service'; -import { SettingsService } from 'app/settings/settings.service'; -import { Dates } from 'app/core/utils/dates'; -import { CdkTextareaAutosize } from '@angular/cdk/text-field'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -/** - * Premature Close Fixed Deposits Account Component - */ -@Component({ - selector: 'mifosx-premature-close-fixed-deposits-account', - templateUrl: './premature-close-fixed-deposits-account.component.html', - styleUrls: ['./premature-close-fixed-deposits-account.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - CdkTextareaAutosize - ] -}) -export class PrematureCloseFixedDepositsAccountComponent implements OnInit { - private formBuilder = inject(UntypedFormBuilder); - private fixedDepositsService = inject(FixedDepositsService); - private dateUtils = inject(Dates); - private route = inject(ActivatedRoute); - private router = inject(Router); - private settingsService = inject(SettingsService); - - /** Minimum date allowed. */ - minDate = new Date(2000, 0, 1); - /** Maximum date allowed. */ - maxDate = new Date(); - /** Premature Close FD Account form. */ - prematureCloseAccountForm: UntypedFormGroup; - /** Savings Account Data */ - savingsAccountsData: any; - /** On account Closure Options */ - onAccountClosureOptions: any; - /** Fixed Deposits Account Id */ - accountId: any; - /** Form submission event */ - isSubmitted = false; - - /** - * @param {FormBuilder} formBuilder Form Builder - * @param {FixedDepositsService} fixedDepositsService Fixed Deposits Service - * @param {Dates} dateUtils Date Utils - * @param {ActivatedRoute} route Activated Route - * @param {Router} router Router - * @param {SettingsService} settingsService Settings Service - */ - constructor() { - this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId']; - } - - /** - * Creates the premature close fd account form. - */ - ngOnInit() { - this.maxDate = this.settingsService.businessDate; - this.createPrematureCloseAccountForm(); - this.buildDependencies(); - } - - /** - * Creates the premature close fd account form. - */ - createPrematureCloseAccountForm() { - this.prematureCloseAccountForm = this.formBuilder.group({ - closedOnDate: [ - '', - Validators.required - ] - }); - } - - /** - * Subscribes to value changes of parent control. - */ - buildDependencies() { - this.prematureCloseAccountForm.get('closedOnDate').valueChanges.subscribe((value: Date) => { - if (!this.isSubmitted) { - this.calculatePrematureAmount(value); - } - }); - } - - /** - * Calculates prematue amount based on closed on date. - * @param {Date} date Premature Close Date - */ - calculatePrematureAmount(date: Date) { - const locale = this.settingsService.language.code; - const dateFormat = this.settingsService.dateFormat; - const data = { - closedOnDate: this.dateUtils.formatDate(date, dateFormat), - dateFormat, - locale - }; - this.fixedDepositsService - .executeFixedDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data) - .subscribe((response: any) => { - this.savingsAccountsData = response.savingsAccounts; - this.onAccountClosureOptions = response.onAccountClosureOptions; - this.prematureCloseAccountForm.addControl( - 'maturityAmount', - new UntypedFormControl({ value: '', disabled: true }) - ); - this.prematureCloseAccountForm.addControl( - 'onAccountClosureId', - new UntypedFormControl('', Validators.required) - ); - this.prematureCloseAccountForm.addControl('note', new UntypedFormControl('')); - this.prematureCloseAccountForm.get('maturityAmount').patchValue(response.maturityAmount); - this.addTransferDetails(); - }); - } - - /** - * Subscribes to value changes of `onAccountClosureId` adds and removes transfer details accordingly. - */ - addTransferDetails() { - this.prematureCloseAccountForm.get('onAccountClosureId').valueChanges.subscribe((id: any) => { - if (id === 200) { - this.prematureCloseAccountForm.addControl( - 'toSavingsAccountId', - new UntypedFormControl('', Validators.required) - ); - this.prematureCloseAccountForm.addControl('transferDescription', new UntypedFormControl('')); - } else { - this.prematureCloseAccountForm.removeControl('toSavingsAccountId'); - this.prematureCloseAccountForm.removeControl('transferDescription'); - } - }); - } - - /** - * Submits the form and premature closes the fd account, - * if successful redirects to the fd account. - */ - submit() { - const prematureCloseAccountFormData = this.prematureCloseAccountForm.value; - this.isSubmitted = true; - const locale = this.settingsService.language.code; - const dateFormat = this.settingsService.dateFormat; - const prevClosedDate: Date = this.prematureCloseAccountForm.value.closedOnDate; - if (prematureCloseAccountFormData.closedOnDate instanceof Date) { - prematureCloseAccountFormData.closedOnDate = this.dateUtils.formatDate(prevClosedDate, dateFormat); - } - const data = { - ...prematureCloseAccountFormData, - dateFormat, - locale - }; - this.fixedDepositsService - .executeFixedDepositsAccountCommand(this.accountId, 'prematureClose', data) - .subscribe(() => { - this.router.navigate(['../../'], { relativeTo: this.route }); - }); - } -} diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/reject-fixed-deposits-account/reject-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/reject-fixed-deposits-account/reject-fixed-deposits-account.component.ts index 82371da17..475037f30 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/reject-fixed-deposits-account/reject-fixed-deposits-account.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/reject-fixed-deposits-account/reject-fixed-deposits-account.component.ts @@ -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 }); + }); } } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component.ts index 227618b1d..2bb7b5f0a 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component.ts @@ -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.` }); } } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component.ts index f871a71a1..de0afd163 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component.ts @@ -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 }); }); } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.module.ts b/src/app/deposits/fixed-deposits/fixed-deposits.module.ts index 6529b1b04..9aa9db4ba 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.module.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.module.ts @@ -35,7 +35,6 @@ import { ViewTransactionComponent } from './fixed-deposit-account-view/view-tran import { WaiveChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/waive-charge-dialog/waive-charge-dialog.component'; import { InactivateChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/inactivate-charge-dialog/inactivate-charge-dialog.component'; import { AddChargeFixedDepositsAccountComponent } from './fixed-deposits-account-actions/add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component'; -import { PrematureCloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component'; import { CloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component'; import { CreateFixedDepositAccountComponent } from './create-fixed-deposit-account/create-fixed-deposit-account.component'; import { FixedDepositAccountDetailsStepComponent } from './fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component'; @@ -78,7 +77,6 @@ import { FixedDepositsCashTransactionComponent } from './fixed-deposits-account- WaiveChargeDialogComponent, InactivateChargeDialogComponent, AddChargeFixedDepositsAccountComponent, - PrematureCloseFixedDepositsAccountComponent, CloseFixedDepositsAccountComponent, CreateFixedDepositAccountComponent, FixedDepositAccountDetailsStepComponent, diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts index b0257e1bc..e5af75631 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts @@ -154,6 +154,33 @@ export class FixedDepositsService { return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams }); } + /** + * FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period + * configured: backdated to the original maturity date within the grace window, or from today + * with a gap-period interest credit (at the linked Savings account's rate) once it has passed. + * @param accountId Fixed Deposit Account Id + * @returns {Observable} { renewedAccountId, maturityDate, maturityAmount, backdated, gapInterestAmount? } + */ + /** `reinvestType` is the staff's choice made at renewal time: 300 = Reinvest Principal + Interest, + * 400 = Reinvest Principal Only (rest of the balance goes to the FD's linked Savings account). */ + renewFixedDepositAccount(accountId: string | number, reinvestType: 300 | 400): Observable { + return this.http.post(`/fixeddepositaccounts/${accountId}/renew`, { reinvestType }); + } + + /** Pre-step the Payment dialog calls, from the FD Dashboard, before submitting the generic `payment` + * command -- credits gap-period interest (at the linked account's rate) if the product's renewal + * grace window has passed; a no-op (`gapInterestAmount: 0`) if still within it. */ + creditGapInterestIfPastGrace(accountId: string | number): Observable { + return this.http.post(`/fixeddepositaccounts/${accountId}/credit-gap-interest`, {}); + } + + /** Read-only -- what a Renew is about to do (current balance, linked account's rate, gap days, + * gap-interest amount, projected new balance), shown in the Payment dialog before anything is + * confirmed. Posts nothing; safe to call as often as the dialog needs. */ + getFdRenewalPreview(accountId: string | number): Observable { + return this.http.get(`/fixeddepositaccounts/${accountId}/renewal-preview`); + } + /** * @param fixedDepositAccount Fixed Deposit Account * @returns {Observable} diff --git a/src/app/dormancy-report/dormancy-report.component.html b/src/app/dormancy-report/dormancy-report.component.html index 52db5ef8d..c316ef8a1 100644 --- a/src/app/dormancy-report/dormancy-report.component.html +++ b/src/app/dormancy-report/dormancy-report.component.html @@ -181,15 +181,15 @@ - @for (r of visibleRows; track r.accountId) { - + @for (r of visibleRows; track r.accountType + '-' + r.accountId) { + @if (tab === 'ESCHEAT') { @@ -203,7 +203,12 @@ {{ branchName(r.branchCode) }} - {{ r.productName }} + + {{ r.productName }} + @if (r.accountType === 'CURRENT') { +
Current Account
+ } + {{ r.balance | number: '1.0-0' }} diff --git a/src/app/dormancy-report/dormancy-report.component.ts b/src/app/dormancy-report/dormancy-report.component.ts index 39c794881..09b5325fd 100644 --- a/src/app/dormancy-report/dormancy-report.component.ts +++ b/src/app/dormancy-report/dormancy-report.component.ts @@ -147,8 +147,24 @@ export class DormancyReportComponent implements OnInit { remitSaving = false; remitError = ''; + /** + * Only savings accounts can be put into a DEAF remittance: remittances are recorded against a savings account, so + * the selection (keyed by account id) holds savings ids alone and a current account with the same id cannot be + * mistaken for one. + */ + isRemittable(row: DormancyRow): boolean { + return row.accountType !== 'CURRENT' && row.remittance !== 'REMITTED'; + } + + isSelected(row: DormancyRow): boolean { + return row.accountType !== 'CURRENT' && this.selected.has(row.accountId); + } + toggleSelect(row: DormancyRow, event: Event): void { event.stopPropagation(); + if (!this.isRemittable(row)) { + return; + } if (this.selected.has(row.accountId)) { this.selected.delete(row.accountId); } else { @@ -158,7 +174,7 @@ export class DormancyReportComponent implements OnInit { /** Ticks every unremitted row on display, or clears them if all are already ticked. */ toggleSelectAll(): void { - const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED'); + const selectable = this.visibleRows.filter((r) => this.isRemittable(r)); if (selectable.every((r) => this.selected.has(r.accountId))) { selectable.forEach((r) => this.selected.delete(r.accountId)); } else { @@ -177,12 +193,12 @@ export class DormancyReportComponent implements OnInit { } get allSelected(): boolean { - const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED'); + const selectable = this.visibleRows.filter((r) => this.isRemittable(r)); return selectable.length > 0 && selectable.every((r) => this.selected.has(r.accountId)); } get selectedTotal(): number { - return this.rows.filter((r) => this.selected.has(r.accountId)).reduce((s, r) => s + r.balance, 0); + return this.rows.filter((r) => this.isSelected(r)).reduce((s, r) => s + r.balance, 0); } openRemit(): void { @@ -316,6 +332,7 @@ export class DormancyReportComponent implements OnInit { const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' }; return { + accountType: a?.accountType || 'SAVINGS', accountId: a?.accountId, accountNo: a?.accountNo || '—', clientId: a?.clientId, diff --git a/src/app/dormancy-report/dormancy-report.compute.ts b/src/app/dormancy-report/dormancy-report.compute.ts index bd491edb2..5867c8bd8 100644 --- a/src/app/dormancy-report/dormancy-report.compute.ts +++ b/src/app/dormancy-report/dormancy-report.compute.ts @@ -80,6 +80,8 @@ export const DORMANCY_STAGES: Record = { export type DeafRemittanceState = 'PENDING' | 'REMITTED'; export interface DormancyRow { + /** SAVINGS or CURRENT. Ids are only unique within one kind, so rows are keyed by both. */ + accountType: string; accountId: number; accountNo: string; clientId: number; diff --git a/src/app/fd-dashboard/fd-dashboard-routing.module.ts b/src/app/fd-dashboard/fd-dashboard-routing.module.ts new file mode 100644 index 000000000..fd20e2e5e --- /dev/null +++ b/src/app/fd-dashboard/fd-dashboard-routing.module.ts @@ -0,0 +1,29 @@ +/** + * 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 { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { Route } from '../core/route/route.service'; +import { FdDashboardComponent } from './fd-dashboard.component'; + +const routes: Routes = [ + Route.withShell([ + { + path: 'fd-dashboard', + component: FdDashboardComponent, + data: { title: 'FD Dashboard', breadcrumb: 'FD Dashboard' } + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class FdDashboardRoutingModule {} diff --git a/src/app/fd-dashboard/fd-dashboard.component.html b/src/app/fd-dashboard/fd-dashboard.component.html new file mode 100644 index 000000000..1c35e6d72 --- /dev/null +++ b/src/app/fd-dashboard/fd-dashboard.component.html @@ -0,0 +1,240 @@ + + +
+ +
+
+
+ +
+
+
+

FD Dashboard & Maturity Ladder

+ Core Banking +
+

+ Active Fixed Deposits positioned against maturity dates. Press ESC to toggle header & footer. +

+
+
+
+ +
+
+ + + @if (errorMessage) { +
+ + {{ errorMessage }} +
+ } + + +
+ @for (key of stageKeys; track key) { + + } +
+ + +
+ + + @if (stageFilter || search) { + + } + +
+ +
+ + {{ visibleRows.length }} of {{ summary.total }} active FDs +
+
+ + + @if (loading) { +
+ +

Loading FD maturity list...

+
+ } @else if (visibleRows.length === 0) { +
+ +

+ @if (!stageFilter && summary.total > 0) { + No FDs are matured or maturing within {{ policy.upcomingDays }} days. Click "On Track" to see all active FDs. + } @else { + No FDs match these filters. + } +

+ @if (stageFilter || search) { + + } +
+ } @else { +
+
+ + + + + + + + + + + + + + + @for (r of visibleRows; track r.id) { + + + + + + + + + + + + + + + + + + } + +
AccountClientProductMaturity DateDeposit AmountMaturity AmountStatusAction
+ {{ r.accountNo || '—' }} + +
+ {{ r.clientName }} +
+
+ {{ r.productName }} + +
+ {{ r.maturityDate }} + + {{ maturityText(r) }} + +
+
+ + {{ r.depositAmount != null ? '₹' + (r.depositAmount | number: '1.2-2') : '—' }} + + + + {{ r.maturityAmount != null ? '₹' + (r.maturityAmount | number: '1.2-2') : '—' }} + + +
+ + {{ r.meta.label }} + + @if (r.renewable) { +
+ {{ graceStatusText(r) }} +
+ } @else if (r.configuredInstructionLabel) { +
Configured: {{ r.configuredInstructionLabel }} (auto-processes)
+ } +
+
+ @if (r.renewable) { + + } @else { + + } +
+
+
+ } +
diff --git a/src/app/fd-dashboard/fd-dashboard.component.scss b/src/app/fd-dashboard/fd-dashboard.component.scss new file mode 100644 index 000000000..cf28bd3ba --- /dev/null +++ b/src/app/fd-dashboard/fd-dashboard.component.scss @@ -0,0 +1,578 @@ +/** + * 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/. + */ + +/** + * Ultra-Compact Light FD Dashboard & Maturity Ladder Styles + * CBSNexora Core Banking Design System + */ + +:host { + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: #0f172a; + background: #f8fafc; + min-height: 100vh; + padding: 12px 16px; + box-sizing: border-box; +} + +.fdd-container { + max-width: 1400px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ── Light Compact Header ────────────────────────────────────────────────── */ + +.fdd-header { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + box-shadow: 0 1px 3px rgb(0 0 0 / 4%); + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + width: 36px; + height: 36px; + border-radius: 8px; + background: #eff6ff; + color: #2563eb; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; + } + + &__title-row { + display: flex; + align-items: center; + gap: 8px; + } + + &__title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: #0f172a; + } + + &__badge { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 6px; + border-radius: 4px; + background: #e0f2fe; + color: #0369a1; + } + + &__sub { + margin: 2px 0 0; + font-size: 11.5px; + color: #64748b; + + kbd { + background: #f1f5f9; + border: 1px solid #cbd5e1; + border-radius: 3px; + padding: 1px 4px; + font-family: inherit; + font-size: 10.5px; + font-weight: 700; + color: #334155; + } + } +} + +/* ── Alerts ─────────────────────────────────────────────────────────────── */ + +.fdd-alert { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + + &--danger { + background: #fef2f2; + border: 1px solid #fca5a5; + color: #dc2626; + } + + &--warning { + background: #fffbeb; + border: 1px solid #fcd34d; + color: #d97706; + } +} + +/* ── Compact Stat Cards Grid ────────────────────────────────────────────── */ + +.fdd-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; +} + +.fdd-stat-card { + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #cbd5e1; + border-radius: 10px; + padding: 10px 14px; + text-align: left; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + border-color: #cbd5e1; + box-shadow: 0 4px 12px rgb(15 23 42 / 6%); + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; + } + + &__label { + font-size: 12px; + font-weight: 700; + color: #334155; + } + + &__icon { + font-size: 14px; + } + + &__val { + font-size: 20px; + font-weight: 800; + line-height: 1.1; + color: #0f172a; + margin-bottom: 2px; + } + + &__sub { + font-size: 10.5px; + color: #64748b; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* Tone styling */ + &--danger { + border-left-color: #ef4444; + + .fdd-stat-card__icon { + color: #ef4444; + } + + &.fdd-stat-card--active { + background: #fef2f2; + border-color: #fca5a5; + } + } + + &--warn { + border-left-color: #f59e0b; + + .fdd-stat-card__icon { + color: #f59e0b; + } + + &.fdd-stat-card--active { + background: #fffbeb; + border-color: #fcd34d; + } + } + + &--info { + border-left-color: #3b82f6; + + .fdd-stat-card__icon { + color: #3b82f6; + } + + &.fdd-stat-card--active { + background: #eff6ff; + border-color: #93c5fd; + } + } + + &--neutral { + border-left-color: #64748b; + + .fdd-stat-card__icon { + color: #64748b; + } + + &.fdd-stat-card--active { + background: #f1f5f9; + border-color: #cbd5e1; + } + } +} + +/* ── Compact Filter Toolbar ─────────────────────────────────────────────── */ + +.fdd-toolbar { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 6px 10px; + display: flex; + align-items: center; + gap: 10px; + box-shadow: 0 1px 2px rgb(0 0 0 / 2%); + + &__spacer { + flex: 1; + } +} + +.fdd-search-box { + position: relative; + display: flex; + align-items: center; + width: 300px; + + &__icon { + position: absolute; + left: 10px; + color: #94a3b8; + font-size: 12px; + pointer-events: none; + } + + &__input { + width: 100%; + padding: 5px 30px 5px 28px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 12px; + font-family: inherit; + color: #0f172a; + background: #fff; + + &:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 2px rgb(37 99 235 / 15%); + } + } + + &__clear { + position: absolute; + right: 6px; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + padding: 2px 4px; + + &:hover { + color: #334155; + } + } +} + +.fdd-count-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + color: #64748b; +} + +/* ── Buttons ────────────────────────────────────────────────────────────── */ + +.fdd-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + border: 1px solid #cbd5e1; + background: #fff; + color: #334155; + transition: all 0.15s ease; + + &:hover:not(:disabled) { + border-color: #2563eb; + color: #2563eb; + background: #f8fafc; + } + + &--slim { + padding: 4px 10px; + font-size: 11.5px; + } + + &--primary { + background: #2563eb; + color: #fff; + border-color: #2563eb; + + &:hover:not(:disabled) { + background: #1d4ed8; + color: #fff; + } + } + + &--action { + background: #2563eb; + color: #fff; + border-color: #2563eb; + padding: 4px 10px; + font-size: 11px; + border-radius: 5px; + + &:hover:not(:disabled) { + background: #1d4ed8; + color: #fff; + } + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.fdd-btn-icon { + width: 26px; + height: 26px; + border-radius: 5px; + border: 1px solid #e2e8f0; + background: #fff; + color: #64748b; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: #eff6ff; + color: #2563eb; + border-color: #93c5fd; + } +} + +/* ── Compact Table ──────────────────────────────────────────────────────── */ + +.fdd-table-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 1px 3px rgb(0 0 0 / 3%); +} + +.fdd-table-wrapper { + overflow-x: auto; +} + +.fdd-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 12px; + + th { + padding: 8px 10px; + background: #f1f5f9; + color: #475569; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; + } + + td { + padding: 7px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + } + + .text-right { + text-align: right; + } + + .text-center { + text-align: center; + } +} + +.fdd-row { + cursor: pointer; + + &:hover { + background: #f8fafc; + } + + &:last-child td { + border-bottom: none; + } +} + +.fdd-acc-num { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + font-weight: 600; + color: #0f172a; +} + +.fdd-client-name { + font-weight: 600; + color: #0f172a; +} + +.fdd-product-tag { + font-size: 11px; + color: #475569; +} + +.fdd-date-cell { + display: flex; + flex-direction: column; + gap: 1px; + + .fdd-date-val { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + color: #0f172a; + } + + .fdd-date-sub { + font-size: 10.5px; + color: #64748b; + + &--overdue { + color: #dc2626; + font-weight: 700; + } + } +} + +.fdd-amount { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + + &--deposit { + color: #334155; + } + + &--maturity { + color: #2563eb; + font-weight: 700; + } +} + +.fdd-status-cell { + display: flex; + flex-direction: column; + gap: 2px; +} + +.fdd-stage { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 10.5px; + font-weight: 700; + width: fit-content; + + &--info { + background: #eff6ff; + color: #2563eb; + } + + &--warn { + background: #fffbeb; + color: #d97706; + } + + &--danger { + background: #fef2f2; + color: #dc2626; + } + + &--neutral { + background: #f1f5f9; + color: #475569; + } +} + +.fdd-grace-sub { + font-size: 10px; + color: #64748b; + + &--warning { + color: #dc2626; + font-weight: 600; + } +} + +.fdd-card.fdd-state-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 24px; + text-align: center; + font-size: 12px; + color: #64748b; + + .fdd-state-icon { + font-size: 24px; + color: #94a3b8; + margin-bottom: 8px; + } + + .fdd-spinner { + font-size: 20px; + color: #2563eb; + margin-bottom: 8px; + } + + .fdd-state-msg { + margin: 0 0 12px; + } +} diff --git a/src/app/fd-dashboard/fd-dashboard.component.ts b/src/app/fd-dashboard/fd-dashboard.component.ts new file mode 100644 index 000000000..a0070503f --- /dev/null +++ b/src/app/fd-dashboard/fd-dashboard.component.ts @@ -0,0 +1,273 @@ +/** + * 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/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; + +/** Custom Modules */ +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** Custom Services */ +import { AccountsService } from '../accounts/accounts.service'; +import { FixedDepositsService } from '../deposits/fixed-deposits/fixed-deposits.service'; +import { toIsoDate } from 'app/core/utils/api-date'; + +/** Dashboard data + computation */ +import { + FD_MATURITY_POLICY, + FD_MATURITY_STAGES, + FdMaturityRow, + FdMaturityStage, + FdMaturitySummary, + buildMaturityRow, + maturitySummary, + sortMaturityRows +} from './fd-dashboard.compute'; +import { PaymentDialogComponent } from '../deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component'; + +/** + * FD Dashboard: every active Fixed Deposit positioned against its own maturity date. + * + * Read-only and computed, the same way the re-KYC expiry worklist is: the ladder (Matured / Due + * Soon / Upcoming / On Track) is worked out here from `maturityDate`, not stored anywhere, so this + * screen can never drift out of step with the account -- it always says what today's date implies. + * Data comes from the same `/fixeddepositaccounts` list Accounts > Fixed Deposit already uses; + * nothing new was added on the backend for this. + */ +@Component({ + selector: 'mifosx-fd-dashboard', + templateUrl: './fd-dashboard.component.html', + styleUrls: ['./fd-dashboard.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent + ] +}) +export class FdDashboardComponent implements OnInit { + private accountsService = inject(AccountsService); + private fixedDepositsService = inject(FixedDepositsService); + private router = inject(Router); + private dialog = inject(MatDialog); + + loading = false; + errorMessage = ''; + + rows: FdMaturityRow[] = []; + + /** Renew action state -- the dialog itself owns submit-in-progress/error display; this just guards + * against opening a second dialog for the same row while one is already up. */ + renewing: number | null = null; + + /** Filters */ + stageFilter: FdMaturityStage | '' = ''; + search = ''; + + readonly policy = FD_MATURITY_POLICY; + readonly stageMeta = FD_MATURITY_STAGES; + /** ON_TRACK is reachable only by clicking its own tile -- the default view stays on what needs + * attention (matured or maturing soon), matching why this screen exists. */ + readonly stageKeys: FdMaturityStage[] = [ + 'MATURED', + 'DUE_SOON', + 'UPCOMING', + 'ON_TRACK' + ]; + + ngOnInit(): void { + this.load(); + } + + /** + * Loads every active FD and re-derives the maturity ladder for each. + * + * Calls the dedicated maturity-dashboard endpoint (backend: FixedDepositMaturityDashboardApiResource), + * not the general Accounts > Fixed Deposit list endpoint -- that one's query is wide (six extra + * table joins for audit/currency/tax fields this screen never shows) and, until this feature + * added them, ran over unindexed columns. The dashboard endpoint already filters to ACTIVE FDs + * (deposit_type_enum/status_enum are fixed in its SQL, not a request parameter), so there's + * nothing to re-check client-side here. + */ + load(): void { + this.loading = true; + this.errorMessage = ''; + const today = toIsoDate(new Date().toISOString()); + + this.accountsService.getFixedDepositMaturityDashboard().subscribe({ + next: (res: any) => { + const items: any[] = Array.isArray(res) ? res : []; + + const mapped = items + .map((a: any) => this.mapAccount(a)) + .map((a) => buildMaturityRow(a, today)) + .filter((r): r is FdMaturityRow => !!r); + + this.rows = sortMaturityRows(mapped); + this.loading = false; + }, + error: (err: any) => { + this.rows = []; + this.loading = false; + this.errorMessage = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the FD maturity list.'; + } + }); + } + + /** Maps one raw `/fixeddepositaccounts/maturity-dashboard` list item onto what the ladder needs. */ + private mapAccount(a: any): { + id: number; + accountNo: string; + clientId?: number; + clientName?: string; + productName?: string; + savingsProductName?: string; + fdType?: string; + maturityDate: string; + depositAmount?: number; + maturityAmount?: number; + onClosureType?: number; + linkedAccountId?: number; + renewalGracePeriodDays?: number; + } { + return { + id: a?.id, + accountNo: a?.accountNo || '', + clientId: a?.clientId, + clientName: a?.clientName, + productName: a?.productName, + savingsProductName: a?.savingsProductName, + fdType: a?.fdType, + maturityDate: toIsoDate(a?.maturityDate), + depositAmount: a?.depositAmount, + maturityAmount: a?.maturityAmount, + onClosureType: a?.onClosureType, + linkedAccountId: a?.linkedAccountId, + renewalGracePeriodDays: a?.renewalGracePeriodDays + }; + } + + // ── Filtering ────────────────────────────────────────────────────────────── + + get visibleRows(): FdMaturityRow[] { + const needle = this.search.trim().toLowerCase(); + const stage = this.stageFilter; + return this.rows.filter((r) => { + if (stage) { + if (r.stage !== stage) { + return false; + } + } else if (r.stage === 'ON_TRACK') { + // No tile selected -- the default view is the worklist, not every active FD. + return false; + } + if (needle) { + const hay = `${r.accountNo} ${r.clientName}`.toLowerCase(); + if (!hay.includes(needle)) { + return false; + } + } + return true; + }); + } + + get summary(): FdMaturitySummary { + return maturitySummary(this.rows); + } + + resetFilters(): void { + this.stageFilter = ''; + this.search = ''; + } + + toggleStage(stage: FdMaturityStage): void { + this.stageFilter = this.stageFilter === stage ? '' : stage; + } + + // ── Navigation ───────────────────────────────────────────────────────────── + + openAccount(row: FdMaturityRow): void { + if (!row.clientId) { + return; + } + this.router.navigate([ + '/clients', + row.clientId, + 'fixed-deposits-accounts', + row.id, + 'general' + ]); + } + + // ── Renewal ──────────────────────────────────────────────────────────────── + + /** Renew is a single action on a matured, renewable row -- opens the same Payment dialog an account + * page's own Account Actions menu uses, scoped to just the Reinvest choices, with the closure date + * and gap-interest crediting decided from the grace window rather than picked here (see + * PaymentDialogComponent's `dashboardRenewal` mode). */ + openRenewDialog(row: FdMaturityRow): void { + if (!row.renewable || this.renewing != null) { + return; + } + this.renewing = row.id; + this.dialog + .open(PaymentDialogComponent, { + width: '720px', + maxWidth: '95vw', + data: { + accountId: row.id, + accountBalance: row.maturityAmount, + maturityDate: row.maturityDate, + dashboardRenewal: { row } + } + }) + .afterClosed() + .subscribe((result: { confirm: boolean } | undefined) => { + this.renewing = null; + if (result?.confirm) { + this.load(); + } + }); + } + + // ── Presentation ─────────────────────────────────────────────────────────── + + stageClass(stage: FdMaturityStage): string { + return `fdd-stage fdd-stage--${FD_MATURITY_STAGES[stage].tone}`; + } + + /** "12 days overdue" / "in 5 days" / "Matures today", as a branch officer would say it. */ + maturityText(row: FdMaturityRow): string { + if (row.daysOverdue > 0) { + return `${row.daysOverdue} day${row.daysOverdue === 1 ? '' : 's'} overdue`; + } + if (row.daysRemaining === 0) { + return 'Matures today'; + } + return `In ${row.daysRemaining} day${row.daysRemaining === 1 ? '' : 's'}`; + } + + /** "Renewable — 6 of 9 grace days left" / "Grace period (9d) passed 3 days ago" / "Renewable" (no + * grace configured) -- the sub-label under a matured, renewable row's stage badge. */ + graceStatusText(row: FdMaturityRow): string { + if (!row.renewalGracePeriodDays) { + return 'Renewable'; + } + const grace = row.renewalGracePeriodDays; + if (row.withinGraceWindow) { + const daysLeft = grace - row.daysOverdue; + return `Renewable — ${daysLeft} of ${grace} grace day${grace === 1 ? '' : 's'} left`; + } + const daysPastGrace = row.daysOverdue - grace; + return `Grace period (${grace}d) passed ${daysPastGrace} day${daysPastGrace === 1 ? '' : 's'} ago`; + } +} diff --git a/src/app/fd-dashboard/fd-dashboard.compute.ts b/src/app/fd-dashboard/fd-dashboard.compute.ts new file mode 100644 index 000000000..bc436192c --- /dev/null +++ b/src/app/fd-dashboard/fd-dashboard.compute.ts @@ -0,0 +1,240 @@ +/** + * 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/. + */ + +/** + * Where an active Fixed Deposit sits relative to its own maturity date. + * + * MATURED accounts are the ones staff actually need to act on -- the FD has run its course but is + * still ACTIVE in the core, meaning nobody has applied the maturity instruction yet (or the + * scheduled job hasn't reached it). DUE_SOON and UPCOMING exist so a branch can see what's coming + * before it lands, not just after. + */ +export type FdMaturityStage = 'MATURED' | 'DUE_SOON' | 'UPCOMING' | 'ON_TRACK'; + +/** Day-count thresholds, measured forward from today toward the maturity date. */ +export const FD_MATURITY_POLICY = { + /** Maturing within this many days counts as DUE_SOON. */ + dueSoonDays: 7, + /** Maturing within this many days (and not already DUE_SOON) counts as UPCOMING. */ + upcomingDays: 30 +}; + +export interface FdMaturityStageMeta { + stage: FdMaturityStage; + label: string; + /** Colour key the template maps to a badge/tile class. */ + tone: 'info' | 'warn' | 'danger' | 'neutral'; + summary: string; +} + +export const FD_MATURITY_STAGES: Record = { + MATURED: { + stage: 'MATURED', + label: 'Matured', + tone: 'danger', + summary: 'Past maturity date and still active -- the maturity instruction has not been applied yet.' + }, + DUE_SOON: { + stage: 'DUE_SOON', + label: `Maturing ≤ ${FD_MATURITY_POLICY.dueSoonDays} Days`, + tone: 'warn', + summary: 'Matures within the week.' + }, + UPCOMING: { + stage: 'UPCOMING', + label: `Maturing ≤ ${FD_MATURITY_POLICY.upcomingDays} Days`, + tone: 'info', + summary: 'Matures within the month.' + }, + ON_TRACK: { + stage: 'ON_TRACK', + label: 'On Track', + tone: 'neutral', + summary: 'Maturity date is further out.' + } +}; + +/** FD types the manual-renewal action applies to. SPECIAL is architecturally forced to Transfer to + * Savings server-side and can never be Reinvest-configured, so it's never renewable here. */ +const RENEWABLE_FD_TYPES = [ + 'SINGLE', + 'DOUBLE' +]; + +/** DepositAccountOnClosureType labels, for the "what will happen automatically" sub-label shown on a + * matured row that already carries an explicit instruction from account-opening. */ +const ON_CLOSURE_TYPE_LABELS: Record = { + 200: 'Transfer to Savings', + 300: 'Reinvest Principal + Interest', + 400: 'Reinvest Principal Only' +}; + +/** One active Fixed Deposit account, positioned on the maturity ladder. */ +export interface FdMaturityRow { + id: number; + accountNo: string; + clientId: number | null; + clientName: string; + productName: string; + maturityDate: string; + depositAmount: number | null; + maturityAmount: number | null; + /** Negative once the maturity date has passed. */ + daysRemaining: number; + /** 0 while not yet matured; how many days past maturity otherwise. */ + daysOverdue: number; + stage: FdMaturityStage; + meta: FdMaturityStageMeta; + /** Null/0 -- no buffer configured; renewing after the maturity date itself pays gap interest. */ + renewalGracePeriodDays: number | null; + linkedAccountId: number | null; + /** The account's own stored DepositAccountOnClosureType, raw -- null when none was chosen at + * account-opening. On a renewable row that DOES carry one (grace-period case: the account picked + * Reinvest, and is only waiting because the product also has a grace window), the Renew dialog + * pre-selects this rather than presenting a neutral, undecided choice -- the maker already decided + * what this FD should do, grace only changed when that gets carried out. */ + onClosureType: number | null; + /** True only when the account was opened with NO maturity instruction at all (the officer left + * Maturity Instructions blank at account-opening) -- that's the one case nothing else will ever + * act on this FD, so the Dashboard's manual Renew action is meaningful. An account that WAS given + * an explicit instruction (Transfer to Savings or either Reinvest option) auto-processes on its + * own via the nightly job, exactly as it always has -- the Dashboard only reports on those, it + * doesn't also offer to renew them. */ + isReinvestEligible: boolean; + /** True once matured AND eligible -- the only case where nothing has auto-processed it, so a + * manual action from the Dashboard is meaningful. */ + renewable: boolean; + /** Only meaningful when renewable -- true if still inside the grace window (backdated renewal, + * no gap interest), false if the window has passed (renews from today, gap interest applies). */ + withinGraceWindow: boolean; + /** Set on a matured row that is NOT renewable because it already carries an explicit instruction -- + * what it was configured to do at account-opening, shown so staff can see at a glance what tonight's + * job will do without opening the account. Null for a renewable row (nothing decided yet) or a row + * that isn't matured. */ + configuredInstructionLabel: string | null; +} + +/** Days between two ISO dates, positive when `later` is after `earlier`. */ +function daysBetween(earlier: string, later: string): number { + const a = new Date(`${earlier}T00:00:00Z`).getTime(); + const b = new Date(`${later}T00:00:00Z`).getTime(); + return Math.round((b - a) / 86400000); +} + +/** Which rung of the ladder an account is on, from how many days remain until maturity. */ +export function maturityStage(daysRemaining: number): FdMaturityStage { + if (daysRemaining <= 0) { + return 'MATURED'; + } + if (daysRemaining <= FD_MATURITY_POLICY.dueSoonDays) { + return 'DUE_SOON'; + } + if (daysRemaining <= FD_MATURITY_POLICY.upcomingDays) { + return 'UPCOMING'; + } + return 'ON_TRACK'; +} + +/** Builds one dashboard row from a raw `/fixeddepositaccounts` list item and today's ISO date. */ +export function buildMaturityRow( + fd: { + id: number; + accountNo: string; + clientId?: number; + clientName?: string; + savingsProductName?: string; + productName?: string; + fdType?: string; + maturityDate: string; + depositAmount?: number; + maturityAmount?: number; + onClosureType?: number; + linkedAccountId?: number; + renewalGracePeriodDays?: number; + }, + today: string +): FdMaturityRow | null { + if (!fd.maturityDate) { + // No maturity date means the account isn't fully assembled yet (e.g. a chart not applied) -- + // nothing sound to bucket it by, so it's left off the worklist rather than shown wrong. + return null; + } + const daysRemaining = daysBetween(today, fd.maturityDate); + const stage = maturityStage(daysRemaining); + // No grace configured reads as a 0-day buffer, not "not renewable" -- it only shrinks the backdate + // window, matching the backend's default (renewMaturedFixedDeposit treats a missing value as 0). + const gracePeriodDays = fd.renewalGracePeriodDays ?? 0; + // Mirrors updateMaturityDetails()'s own "will I auto-process this?" check exactly, inverted: the + // nightly job leaves an account alone (never touches it) only when it was opened with NO maturity + // instruction at all -- that's the one case the Dashboard's Renew action exists for. An account + // opened WITH an explicit instruction auto-processes tonight regardless of the product's renewal + // grace period (the grace window never delays it), so it's never renewable here either way. + const isReinvestEligible = + !!fd.fdType && RENEWABLE_FD_TYPES.includes(fd.fdType as string) && fd.onClosureType == null; + const renewable = stage === 'MATURED' && isReinvestEligible; + const withinGraceWindow = renewable && daysOverdueFor(daysRemaining) <= gracePeriodDays; + const configuredInstructionLabel = + stage === 'MATURED' && !renewable && fd.onClosureType != null + ? (ON_CLOSURE_TYPE_LABELS[fd.onClosureType] ?? null) + : null; + + return { + id: fd.id, + accountNo: fd.accountNo || '', + clientId: fd.clientId ?? null, + clientName: fd.clientName || '—', + productName: fd.productName || fd.savingsProductName || '—', + maturityDate: fd.maturityDate, + depositAmount: fd.depositAmount ?? null, + maturityAmount: fd.maturityAmount ?? null, + daysRemaining, + daysOverdue: daysOverdueFor(daysRemaining), + stage, + meta: FD_MATURITY_STAGES[stage], + renewalGracePeriodDays: gracePeriodDays, + linkedAccountId: fd.linkedAccountId ?? null, + onClosureType: fd.onClosureType ?? null, + isReinvestEligible, + renewable, + withinGraceWindow, + configuredInstructionLabel + }; +} + +function daysOverdueFor(daysRemaining: number): number { + return daysRemaining < 0 ? -daysRemaining : 0; +} + +export interface FdMaturitySummary { + total: number; + matured: number; + dueSoon: number; + upcoming: number; + onTrack: number; +} + +export function maturitySummary(rows: FdMaturityRow[]): FdMaturitySummary { + return { + total: rows.length, + matured: rows.filter((r) => r.stage === 'MATURED').length, + dueSoon: rows.filter((r) => r.stage === 'DUE_SOON').length, + upcoming: rows.filter((r) => r.stage === 'UPCOMING').length, + onTrack: rows.filter((r) => r.stage === 'ON_TRACK').length + }; +} + +/** Sorts the worklist so the most overdue, then the soonest-to-mature, rows come first. */ +export function sortMaturityRows(rows: FdMaturityRow[]): FdMaturityRow[] { + const rank: Record = { + MATURED: 0, + DUE_SOON: 1, + UPCOMING: 2, + ON_TRACK: 3 + }; + return [...rows].sort((a, b) => rank[a.stage] - rank[b.stage] || a.daysRemaining - b.daysRemaining); +} diff --git a/src/app/fd-dashboard/fd-dashboard.module.ts b/src/app/fd-dashboard/fd-dashboard.module.ts new file mode 100644 index 000000000..d09c71fe5 --- /dev/null +++ b/src/app/fd-dashboard/fd-dashboard.module.ts @@ -0,0 +1,22 @@ +/** + * 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 { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { FdDashboardRoutingModule } from './fd-dashboard-routing.module'; +import { FdDashboardComponent } from './fd-dashboard.component'; + +@NgModule({ + imports: [ + SharedModule, + FdDashboardRoutingModule, + FdDashboardComponent + ] +}) +export class FdDashboardModule {} diff --git a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html index cf231ab89..e331d938c 100644 --- a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html +++ b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html @@ -18,9 +18,16 @@

NDTL · CRR/SLR Computation Base · As per RBI Master Circular

+ +
+ + {{ unavailable }} +
+
+
- CRR Required (3%) - ₹2,077 Cr + CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }} + {{ crrSlr.crrRequired }}
- SLR Required (18%) - ₹12,460 Cr + SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }} + {{ crrSlr.slrRequired }}
-
Within Limits
+ +
+ @if (crrSlr.status === 'ok') { + Within Limits + } @else if (crrSlr.status === 'short') { + Shortfall + } @else { + Not computed + } +
diff --git a/src/app/home/dashboard/dashboard.component.scss b/src/app/home/dashboard/dashboard.component.scss index c0d81a80e..1cde1703d 100644 --- a/src/app/home/dashboard/dashboard.component.scss +++ b/src/app/home/dashboard/dashboard.component.scss @@ -444,6 +444,17 @@ $purple: #7c3aed; font-weight: 600; } +/* A figure that could not be computed. Deliberately not green: a tile that cannot read the ledger + has not found the bank compliant, and a success tag on an unknown position is a false all-clear. */ +.tag-muted { + background: #f3f4f6; + color: #4b5563; + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; +} + /* ─── Compact Detail Cards ─── */ .dash-card.compact { padding: 12px 16px; diff --git a/src/app/home/dashboard/dashboard.component.ts b/src/app/home/dashboard/dashboard.component.ts index 877f5b3f7..c0605e700 100644 --- a/src/app/home/dashboard/dashboard.component.ts +++ b/src/app/home/dashboard/dashboard.component.ts @@ -19,6 +19,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { AsyncPipe, DatePipe } from '@angular/common'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RbiCircularsService } from '../../rbi-circulars/rbi-circulars.service'; /** * Dashboard component. */ @@ -40,9 +41,29 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class DashboardComponent implements OnInit { private router = inject(Router); private dialog = inject(MatDialog); + private rbi = inject(RbiCircularsService); today = new Date(); + /** + * The CRR/SLR tile, read from the return. + * + * The amounts and the rates were both fixed text on the template until now, so the tile reported + * ₹2,077 Cr at 3% whatever the ledger held and whatever rate Admin was on. A dashboard figure is + * read as the bank's position, and one that cannot move is worse than none: nothing on the tile + * says it is a placeholder. + * + * Dashes until the return answers. 'status' drives the compliance tag, which is only claimed + * where the return states it. + */ + crrSlr: { + crrRate: number | null; + slrRate: number | null; + crrRequired: string; + slrRequired: string; + status: 'ok' | 'short' | 'unknown'; + } = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' }; + /** Array of all user activities */ userActivity: string[]; /** Array of most recent user activities */ @@ -67,6 +88,45 @@ export class DashboardComponent implements OnInit { this.recentActivities = this.getRecentActivities(); this.frequentActivities = this.getFrequentActivities(); this.setFilteredActivities(); + this.loadCrrSlr(); + } + + /** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */ + private loadCrrSlr(): void { + const crore = (v: number | null | undefined): string => + v === null || v === undefined ? '—' : `₹${Math.round(v / 1e7).toLocaleString('en-IN')} Cr`; + + // The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at + // the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never + // reported, so a tile built on it shows a position that appears on no filing. + this.rbi.getForm1Statutory().subscribe({ + next: (data: any) => { + const latest = (data?.columns?.length ?? 0) - 1; + if (latest < 0) { + return; + } + this.crrSlr.crrRate = data?.crrPercentage ?? null; + this.crrSlr.slrRate = data?.slrPercentage ?? null; + this.crrSlr.crrRequired = crore(data?.partB?.required?.[latest]); + this.crrSlr.slrRequired = crore(data?.partC?.required?.[latest]); + + // Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a + // bank short on SLR is not within limits because its cash reserve happens to be ample. + const crrHeld = data?.partB?.maintained?.[latest]; + const crrNeed = data?.partB?.required?.[latest]; + const slrHeld = data?.partC?.maintained?.[latest]; + const slrNeed = data?.partC?.required?.[latest]; + if ([ + crrHeld, + crrNeed, + slrHeld, + slrNeed + ].every((v) => v !== null && v !== undefined)) { + this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short'; + } + }, + error: () => undefined + }); } /** diff --git a/src/app/interest-posting-report/interest-posting-report.component.html b/src/app/interest-posting-report/interest-posting-report.component.html new file mode 100644 index 000000000..b830e7b92 --- /dev/null +++ b/src/app/interest-posting-report/interest-posting-report.component.html @@ -0,0 +1,322 @@ + + +
+
+
+

Interest Posting Reports

+
+ Each branch gets its own report for each savings product, generated automatically at 12:00 PM on the posting + date and open to generating or regenerating at any time. The branch approves it, and its approved interest is + posted when the branch closes the day — it cannot close until then. The head office closes last. +
+
+
+ + + +
+
+ + @if (isHeadOffice) { +
+ + +
+ @if (tab === 'others') { +
+ +
+ Each branch generates and approves its own reports. The head office can act here only for a branch that has + not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO). +
+
+ } + } + + @if (showPreview) { +
+
+
+

Interest preview — earned up to {{ previewAsOf }}

+
+ What each account would be paid if interest were posted today. View only — nothing is saved, approved or + posted. +
+
+
+ Total so far + ₹{{ previewTotal | number: '1.2-2' }} + {{ previewRows.length }} account(s) +
+
+
+ + + +
+ @if (previewLoading) { +
Calculating interest up to today…
+ } @else { + + + + + + + + + + + + @for (g of previewGroups; track g.officeName + g.productName) { + + + + + + + + } @empty { + + + + } + +
BranchProductPosting dateAccountsInterest so far
{{ g.officeName }} + {{ g.productName }} + {{ g.postingDate }}{{ g.accounts }}₹{{ g.interest | number: '1.2-2' }}
No savings account has interest accrued in its current cycle.
+ @if (previewRows.length > 0) { + + + + + + + + + + + + + + + @for (r of previewRows; track r.savingsAccountId) { + + + + + + + + + + + } + + + + + + + +
BranchProductAccount noAccount holderBalanceRate %CycleInterest so far
{{ r.officeName }}{{ r.productName }}{{ r.accountNo }}{{ r.clientName }}₹{{ r.balance | number: '1.2-2' }}{{ r.rate | number: '1.2-2' }}{{ r.cycleStart }} → {{ r.postingDate }}₹{{ r.interest | number: '1.2-2' }}
Total + ₹{{ previewTotal | number: '1.2-2' }} +
+ } + } +
+ } + + @if (errorMessage) { +
+ +
{{ errorMessage }}
+
+ } + @if (notice) { +
+ +
{{ notice }}
+
+ } + @if (pendingCount > 0) { +
+ +
{{ pendingCount }} report(s) waiting for approval. The day cannot be closed until they are approved.
+
+ } + + @if (due.length > 0) { +
+ +
+
Interest is due but no report has been generated yet for:
+ @for (d of due; track d.productId + '-' + d.officeId) { +
+ {{ d.productName }} · {{ d.officeName }} · {{ d.postingDate }} + @if (d.canAct) { + + } @else if (d.blockedReason) { + {{ d.blockedReason }} + } +
+ } +
+
+ } + +
+ + + + + + + + + + + + + + + + @for (r of reports; track r.id) { + + + + + + + + + + + + } @empty { + + + + } + +
BranchProductPosting dateAccountsTotal interestStatusGeneratedApproved
{{ r.officeName }} + {{ r.productName }} + {{ r.postingDate }}{{ r.accountCount }}₹{{ r.totalInterest | number: '1.2-2' }} + {{ r.status }} + @if (r.failureReason) { +
Posting failed: {{ r.failureReason }}
+ } +
+ {{ r.generatedBy || 'System' }} · {{ r.generationMode }}
{{ r.generatedAt | date: 'dd MMM, HH:mm' }} +
+ @if (r.approvedAt) { + {{ r.approvedBy }} + @if (r.approvedOnBehalf) { + (HO) + } +
{{ r.approvedAt | date: 'dd MMM, HH:mm' }} + } @else { + — + } +
+ @if (canRegenerate(r)) { + + } + @if (canApprove(r)) { + + } + @if (!r.canAct && r.blockedReason && (r.status === 'GENERATED' || r.status === 'APPROVED')) { + {{ r.blockedReason }} + } +
+ @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. + } +
+
+ + @if (selected) { +
+
+
+

{{ selected.officeName }} · {{ selected.productName }} — {{ selected.postingDate }}

+
+ 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 +
+
+
+ Total interest + ₹{{ selected.totalInterest | number: '1.2-2' }} + {{ selected.accountCount }} account(s) +
+
+ + + + + + + + + + + + + @for (l of selected.lines; track l.savingsAccountId) { + + + + + + + + + } + + + + + + + + +
Account noAccount holderInterestBalance before postingBalance after postingPosted txn
{{ l.accountNo }}{{ l.clientName }}₹{{ l.interestAmount | number: '1.2-2' }}₹{{ l.balanceBefore | number: '1.2-2' }}₹{{ l.balanceAfter | number: '1.2-2' }}{{ l.savingsTransactionId ? '#' + l.savingsTransactionId : '—' }}
Total + ₹{{ selected.totalInterest | number: '1.2-2' }} +
+
+ } +
diff --git a/src/app/interest-posting-report/interest-posting-report.component.scss b/src/app/interest-posting-report/interest-posting-report.component.scss new file mode 100644 index 000000000..a4d744927 --- /dev/null +++ b/src/app/interest-posting-report/interest-posting-report.component.scss @@ -0,0 +1,327 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +:host { + --i-navy: #1a237e; + --i-blue: #1d4ed8; + --i-blue-l: #eff6ff; + --i-green: #15803d; + --i-green-l: #dcfce7; + --i-amber: #b45309; + --i-amber-l: #fef3c7; + --i-red: #c0392b; + --i-red-l: #fdecea; + --i-g100: #f1f5f9; + --i-g200: #e2e8f0; + --i-g500: #64748b; + --i-g900: #0f172a; + + display: block; + font-family: 'DM Sans', system-ui, sans-serif; + color: var(--i-g900); +} + +.ipr { + max-width: 1280px; + margin: 0 auto; + padding: 16px; +} + +.ipr-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-end; + gap: 12px; + margin-bottom: 14px; +} + +.ipr-head-t { + margin: 0; + font-size: 22px; + color: var(--i-navy); +} + +.ipr-head-s { + max-width: 720px; + margin-top: 4px; + font-size: 13px; + color: var(--i-g500); +} + +.ipr-head-a { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.ipr-date { + display: flex; + flex-direction: column; + font-size: 11px; + color: var(--i-g500); + + input, + select { + padding: 6px 8px; + border: 1px solid var(--i-g200); + border-radius: 6px; + font: inherit; + font-size: 13px; + } +} + +.ipr-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border: 1px solid var(--i-g200); + border-radius: 6px; + background: #fff; + font: inherit; + font-size: 13px; + cursor: pointer; + + &:disabled { + opacity: 0.55; + cursor: default; + } +} + +.ipr-btn--primary { + border-color: var(--i-blue); + background: var(--i-blue); + color: #fff; +} + +.ipr-btn--slim { + padding: 4px 10px; + font-size: 12px; +} + +.ipr-alert { + display: flex; + gap: 8px; + margin-bottom: 10px; + padding: 10px 14px; + border-radius: 8px; + font-size: 13px; +} + +.ipr-alert--error { + background: var(--i-red-l); + color: var(--i-red); +} + +.ipr-alert--ok { + background: var(--i-green-l); + color: var(--i-green); +} + +.ipr-alert--warn { + background: var(--i-amber-l); + color: var(--i-amber); +} + +.ipr-card { + margin-bottom: 14px; + border: 1px solid var(--i-g200); + border-radius: 10px; + background: #fff; + overflow-x: auto; +} + +.ipr-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 9px 12px; + border-bottom: 1px solid var(--i-g100); + text-align: left; + vertical-align: top; + } + + th { + background: var(--i-g100); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--i-g500); + } + + tbody tr { + cursor: pointer; + } + + tbody tr.is-selected { + background: var(--i-blue-l); + } + + .num { + text-align: right; + white-space: nowrap; + } + + .actions { + display: flex; + gap: 6px; + justify-content: flex-end; + cursor: default; + } + + tfoot td { + background: var(--i-g100); + } +} + +.muted { + color: var(--i-g500); + font-size: 12px; +} + +.ipr-empty { + padding: 24px !important; + text-align: center !important; + color: var(--i-g500); +} + +.ipr-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; +} + +.ipr-badge--pending { + background: var(--i-amber-l); + color: var(--i-amber); +} + +.ipr-badge--approved { + background: var(--i-blue-l); + color: var(--i-blue); +} + +.ipr-badge--posted { + background: var(--i-green-l); + color: var(--i-green); +} + +.ipr-badge--muted { + background: var(--i-g100); + color: var(--i-g500); +} + +.ipr-fail { + margin-top: 4px; + font-size: 11px; + color: var(--i-red); +} + +.ipr-detail-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + + h3 { + margin: 0 0 4px; + font-size: 16px; + color: var(--i-navy); + } +} + +.ipr-total { + display: flex; + flex-direction: column; + align-items: flex-end; + + strong { + font-size: 22px; + color: var(--i-navy); + } +} + +.ipr-due-body { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.ipr-due-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +// Approved by the head office on behalf of a branch that did not open that day. +.ipr-behalf { + margin-left: 4px; + font-size: 11px; + font-weight: 600; + color: var(--i-amber); +} + +// Preview: refresh / export / close, above the totals. +.ipr-preview-actions { + display: flex; + gap: 6px; + justify-content: flex-end; + padding: 0 16px 10px; +} + +.ipr-tabs { + display: flex; + gap: 4px; + margin-bottom: 12px; + border-bottom: 1px solid var(--i-g200); +} + +.ipr-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + margin-bottom: -1px; + font-size: 13px; + font-weight: 600; + color: var(--i-g500); + cursor: pointer; + background: transparent; + border: 1px solid transparent; + border-radius: 6px 6px 0 0; + + &.is-active { + color: var(--i-navy); + background: #fff; + border-color: var(--i-g200); + border-bottom-color: #fff; + } +} + +.ipr-alert--info { + background: var(--i-blue-l); + color: var(--i-blue); +} + +.ipr-blocked { + display: inline-block; + max-width: 320px; + font-size: 11px; + line-height: 1.35; + color: var(--i-g500); + white-space: normal; +} diff --git a/src/app/interest-posting-report/interest-posting-report.component.ts b/src/app/interest-posting-report/interest-posting-report.component.ts new file mode 100644 index 000000000..c4fd3f1ad --- /dev/null +++ b/src/app/interest-posting-report/interest-posting-report.component.ts @@ -0,0 +1,398 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { SettingsService } from 'app/settings/settings.service'; +import { exportWorkbook } from '../cashier/cashier-export'; +import { + InterestPostingDue, + InterestPostingReport, + InterestPostingReportDetail, + InterestPostingReportService, + InterestPreviewRow, + ReportScope +} from './interest-posting-report.service'; + +/** + * Savings interest posting reports. + * + * On a product's posting date the system generates, at noon, the list of what each savings account + * will be paid. It is reviewed and approved here; the approved amounts are posted when the last + * branch closes the day, and until then no branch can close. Regenerating replaces the report and + * needs approving again. + */ +@Component({ + selector: 'mifosx-interest-posting-report', + templateUrl: './interest-posting-report.component.html', + styleUrls: ['./interest-posting-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent + ] +}) +export class InterestPostingReportComponent implements OnInit { + private reportService = inject(InterestPostingReportService); + private settingsService = inject(SettingsService); + + postingDate = ''; + loading = false; + busy = false; + errorMessage = ''; + notice = ''; + + reports: InterestPostingReport[] = []; + selected: InterestPostingReportDetail | null = null; + /** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */ + due: InterestPostingDue[] = []; + + /** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */ + tab: ReportScope = 'own'; + isHeadOffice = false; + myBranch = ''; + + ngOnInit(): void { + this.postingDate = this.businessDateIso(); + this.reportService.context().subscribe({ + next: (ctx) => { + this.isHeadOffice = !!ctx?.headOffice; + this.myBranch = ctx?.officeName ?? ''; + }, + error: () => (this.isHeadOffice = false) + }); + this.load(); + } + + switchTab(tab: ReportScope): void { + if (this.tab === tab) { + return; + } + this.tab = tab; + this.selected = null; + this.notice = ''; + this.load(); + } + + /** Acting for another branch is the head office's exception, so it is spelt out before it happens. */ + private confirmOnBehalf(officeName: string, action: string): boolean { + if (this.tab !== 'others') { + return true; + } + return confirm( + `${officeName} has not opened today. You are about to ${action} its interest posting report on its behalf, ` + + 'from the head office. Continue?' + ); + } + + load(): void { + this.loading = true; + this.errorMessage = ''; + this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({ + next: (reports) => { + this.reports = reports ?? []; + this.loading = false; + const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id); + if (stillThere) { + this.open(stillThere); + } else { + this.selected = null; + } + }, + error: (err) => { + this.loading = false; + this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.'); + } + }); + this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({ + next: (due) => (this.due = due ?? []), + error: () => (this.due = []) + }); + } + + generate(item: InterestPostingDue): void { + if (!this.confirmOnBehalf(item.officeName, 'generate')) { + return; + } + this.busy = true; + this.notice = ''; + this.errorMessage = ''; + this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({ + next: (res) => { + this.busy = false; + this.notice = `Report generated for ${item.productName} — ${item.officeName}. Review and approve it.`; + this.load(); + this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail)); + }, + error: (err) => { + this.busy = false; + this.errorMessage = this.messageOf(err, 'Could not generate the report.'); + } + }); + } + + open(report: InterestPostingReport): void { + this.errorMessage = ''; + this.reportService.detail(report.id).subscribe({ + next: (detail) => (this.selected = detail), + error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.')) + }); + } + + regenerate(report: InterestPostingReport): void { + if ( + !confirm( + `Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.` + ) || + !this.confirmOnBehalf(report.officeName, 'regenerate') + ) { + return; + } + this.busy = true; + this.notice = ''; + this.errorMessage = ''; + this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({ + next: (res) => { + this.busy = false; + this.notice = `Report regenerated for ${report.productName}. Review and approve it.`; + this.selected = null; + this.load(); + this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail)); + }, + error: (err) => { + this.busy = false; + this.errorMessage = this.messageOf(err, 'Could not regenerate the report.'); + } + }); + } + + approve(report: InterestPostingReport): void { + if ( + !confirm( + `Approve interest of ₹${report.totalInterest} across ${report.accountCount} account(s) for ${report.productName}? ` + + 'It will be posted when the branch closes the day.' + ) || + !this.confirmOnBehalf(report.officeName, 'approve') + ) { + return; + } + this.busy = true; + this.notice = ''; + this.errorMessage = ''; + this.reportService.approve(report.id).subscribe({ + next: () => { + this.busy = false; + this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`; + this.load(); + }, + error: (err) => { + this.busy = false; + this.errorMessage = this.messageOf(err, 'Could not approve the report.'); + } + }); + } + + canRegenerate(report: InterestPostingReport): boolean { + return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED'); + } + + canApprove(report: InterestPostingReport): boolean { + return report.canAct && report.status === 'GENERATED'; + } + + statusClass(status: string): string { + return ( + { + GENERATED: 'ipr-badge--pending', + APPROVED: 'ipr-badge--approved', + POSTED: 'ipr-badge--posted', + SUPERSEDED: 'ipr-badge--muted' + }[status] ?? 'ipr-badge--muted' + ); + } + + /** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */ + get pendingCount(): number { + return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0; + } + + // ── Preview: what each account has earned so far, as if posted today. View only. ── + + showPreview = false; + previewLoading = false; + previewRows: InterestPreviewRow[] = []; + previewAsOf = ''; + + openPreview(): void { + this.showPreview = true; + this.previewLoading = true; + this.errorMessage = ''; + this.reportService.preview().subscribe({ + next: (rows) => { + this.previewRows = rows ?? []; + this.previewAsOf = this.previewRows[0]?.asOf ?? this.businessDateIso(); + this.previewLoading = false; + }, + error: (err) => { + this.previewLoading = false; + this.previewRows = []; + this.errorMessage = this.messageOf(err, 'Could not load the interest preview.'); + } + }); + } + + closePreview(): void { + this.showPreview = false; + } + + get previewTotal(): number { + return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0); + } + + /** Totals by branch and product, for the summary above the account list and the export. */ + get previewGroups(): { + officeName: string; + productName: string; + postingDate: string; + accounts: number; + interest: number; + }[] { + const map = new Map< + string, + { officeName: string; productName: string; postingDate: string; accounts: number; interest: number } + >(); + this.previewRows.forEach((r) => { + const key = `${r.officeName}|${r.productName}`; + const g = map.get(key) ?? { + officeName: r.officeName, + productName: r.productName, + postingDate: r.postingDate, + accounts: 0, + interest: 0 + }; + g.accounts += 1; + g.interest += Number(r.interest || 0); + map.set(key, g); + }); + return [...map.values()]; + } + + exportPreview(): void { + const stamp = `Interest earned to ${this.previewAsOf} — preview, not posted`; + exportWorkbook( + [ + { + sheetName: 'By account', + title: 'Savings Interest Preview', + subtitle: stamp, + headers: [ + 'Branch', + 'Product', + 'Account No', + 'Customer', + 'Balance', + 'Rate %', + 'Cycle Start', + 'Posting Date', + 'Interest So Far' + ], + widths: [ + 18, + 22, + 20, + 26, + 16, + 8, + 12, + 12, + 16 + ], + rows: [ + ...this.previewRows.map((r) => [ + r.officeName, + r.productName, + r.accountNo, + r.clientName, + Number(r.balance || 0), + Number(r.rate || 0), + r.cycleStart, + r.postingDate, + Number(r.interest || 0) + ]), + [ + 'TOTAL', + '', + '', + '', + '', + '', + '', + '', + this.previewTotal + ] + ] + }, + { + sheetName: 'By branch & product', + title: 'Savings Interest Preview — Totals', + subtitle: stamp, + headers: [ + 'Branch', + 'Product', + 'Posting Date', + 'Accounts', + 'Interest So Far' + ], + widths: [ + 18, + 22, + 12, + 10, + 16 + ], + rows: [ + ...this.previewGroups.map((g) => [ + g.officeName, + g.productName, + g.postingDate, + g.accounts, + g.interest + ]), + [ + 'TOTAL', + '', + '', + this.previewRows.length, + this.previewTotal + ] + ] + } + ], + `savings-interest-preview-${this.previewAsOf}` + ); + } + + private businessDateIso(): string { + const businessDate = this.settingsService.businessDate as any; + const parsed = businessDate ? new Date(businessDate) : new Date(); + if (isNaN(parsed.getTime())) { + return new Date().toISOString().slice(0, 10); + } + const month = String(parsed.getMonth() + 1).padStart(2, '0'); + const day = String(parsed.getDate()).padStart(2, '0'); + return `${parsed.getFullYear()}-${month}-${day}`; + } + + private messageOf(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback + ); + } +} diff --git a/src/app/interest-posting-report/interest-posting-report.service.ts b/src/app/interest-posting-report/interest-posting-report.service.ts new file mode 100644 index 000000000..7d2466e49 --- /dev/null +++ b/src/app/interest-posting-report/interest-posting-report.service.ts @@ -0,0 +1,180 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +export type InterestPostingReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED'; + +/** Which branches a list covers: the user's own, or (head office only) every other branch. */ +export type ReportScope = 'own' | 'others'; + +export interface InterestPostingReport { + id: number; + productId: number; + productName: string; + officeId: number; + officeName: string; + postingDate: string; + status: InterestPostingReportStatus; + totalInterest: number; + accountCount: number; + generationMode: 'AUTO' | 'MANUAL'; + generatedAt: string; + generatedBy: string | null; + approvedAt: string | null; + approvedBy: string | null; + approvedOnBehalf: boolean; + postedAt: string | null; + failureReason: string | null; + /** Whether the signed-in user may regenerate or approve it: their own branch, or the head office for a branch that did not open. */ + canAct: boolean; + /** Why not, where canAct is false. */ + blockedReason: string | null; +} + +export interface InterestPostingReportLine { + savingsAccountId: number; + accountNo: string; + clientName: string; + interestAmount: number; + balanceBefore: number; + balanceAfter: number; + savingsTransactionId: number | null; +} + +export interface InterestPostingReportDetail extends InterestPostingReport { + payableGlCode: string | null; + payableGlName: string | null; + paidGlCode: string | null; + paidGlName: string | null; + expenseGlCode: string | null; + expenseGlName: string | null; + lines: InterestPostingReportLine[]; +} + +export interface InterestPostingDue { + productId: number; + productName: string; + officeId: number; + officeName: string; + postingDate: string; + canAct: boolean; + blockedReason: string | null; +} + +export interface InterestPostingSummaryRow { + id: number; + postingDate: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + accountCount: number; + totalInterest: number; + status: InterestPostingReportStatus; + approvedBy: string | null; + approvedOnBehalf: boolean; + approvedAt: string | null; + postedAt: string | null; +} + +export interface InterestPreviewRow { + officeId: number; + officeName: string; + productId: number; + productName: string; + savingsAccountId: number; + accountNo: string; + clientName: string; + balance: number; + rate: number; + cycleStart: string; + postingDate: string; + asOf: string; + interest: number; +} + +/** + * The savings interest posting reports, one per branch and product: generated at noon on a posting + * date, approved by the branch, posted when the branch closes the day. + */ +@Injectable({ providedIn: 'root' }) +export class InterestPostingReportService { + private http = inject(HttpClient); + private readonly base = '/savingsinterestpostingreports'; + + /** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */ + list( + postingDate?: string, + status?: string, + officeId?: number, + scope?: ReportScope + ): Observable { + let params = new HttpParams(); + if (scope) params = params.set('scope', scope); + if (postingDate) params = params.set('postingDate', postingDate); + if (status) params = params.set('status', status); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get(this.base, { params }); + } + + /** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */ + dueWithoutReport(postingDate?: string, scope?: ReportScope): Observable { + let params = new HttpParams(); + if (scope) params = params.set('scope', scope); + if (postingDate) params = params.set('postingDate', postingDate); + return this.http.get(`${this.base}/due-without-report`, { params }); + } + + /** The signed-in user's branch, and whether it is the head office. */ + context(): Observable<{ officeId: number; officeName: string; headOffice: boolean }> { + return this.http.get<{ officeId: number; officeName: string; headOffice: boolean }>(`${this.base}/context`); + } + + detail(reportId: number): Observable { + return this.http.get(`${this.base}/${reportId}`); + } + + regenerate( + productId: number, + officeId: number, + postingDate: string + ): Observable<{ reportId: number; status: string }> { + return this.http.post<{ reportId: number; status: string }>( + this.base, + { productId, officeId, postingDate }, + { params: new HttpParams().set('command', 'regenerate') } + ); + } + + approve(reportId: number): Observable<{ reportId: number; status: string }> { + return this.http.post<{ reportId: number; status: string }>( + `${this.base}/${reportId}`, + {}, + { params: new HttpParams().set('command', 'approve') } + ); + } + + /** Interest each account has earned so far this cycle, as if posted today. View only. */ + preview(officeId?: number): Observable { + let params = new HttpParams(); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get(`${this.base}/preview`, { params }); + } + + /** The head office's branch-wise view over a date range. */ + summary(fromDate?: string, toDate?: string, productId?: number): Observable { + let params = new HttpParams(); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + if (productId) params = params.set('productId', String(productId)); + return this.http.get(`${this.base}/summary`, { params }); + } +} diff --git a/src/app/interest-posting-summary/interest-posting-summary.component.html b/src/app/interest-posting-summary/interest-posting-summary.component.html new file mode 100644 index 000000000..8ecca4e98 --- /dev/null +++ b/src/app/interest-posting-summary/interest-posting-summary.component.html @@ -0,0 +1,172 @@ + + +
+
+
+

Interest Posting Summary

+
+ Savings interest across every branch: for each posting date, branch and product, how many customers were paid + and how much. Each branch approves and posts its own; this is the head office's view. +
+
+
+ + + + + +
+
+ + @if (errorMessage) { +
+ +
{{ errorMessage }}
+
+ } + @if (pendingCount > 0) { +
+ +
{{ pendingCount }} branch report(s) in this period are not posted yet.
+
+ } + +
+
+
+

Branch totals

+
{{ fromDate }} to {{ toDate }}
+
+
+ Total interest + ₹{{ totalInterest | number: '1.2-2' }} + {{ totalCustomers }} customer(s) · posted ₹{{ postedInterest | number: '1.2-2' }} +
+
+ + + + + + + + + + + + + @for (b of byBranch; track b.officeName) { + + + + + + + + + } @empty { + + + + } + + @if (byBranch.length > 0) { + + + + + + + + + + + } +
BranchReportsCustomersInterestPostedNot yet posted
+ {{ b.officeName }} + {{ b.reports }}{{ b.customers }}₹{{ b.interest | number: '1.2-2' }}₹{{ b.posted | number: '1.2-2' }}₹{{ b.interest - b.posted | number: '1.2-2' }}
+ @if (loading) { + Loading… + } @else { + No interest posting report in this period. + } +
Total + {{ shown.length }} + + {{ totalCustomers }} + + ₹{{ totalInterest | number: '1.2-2' }} + + ₹{{ postedInterest | number: '1.2-2' }} + + ₹{{ totalInterest - postedInterest | number: '1.2-2' }} +
+
+ +
+ + + + + + + + + + + + + + + @for (r of shown; track r.id) { + + + + + + + + + + + } + +
Posting dateBranchProductCustomersInterestStatusApprovedPosted
{{ r.postingDate }}{{ r.officeName }} + {{ r.productName }} + {{ r.accountCount }}₹{{ r.totalInterest | number: '1.2-2' }} + {{ r.status }} + + @if (r.approvedAt) { + {{ r.approvedBy }} + @if (r.approvedOnBehalf) { + (HO) + } +
{{ r.approvedAt | date: 'dd MMM, HH:mm' }} + } @else { + — + } +
{{ r.postedAt ? (r.postedAt | date: 'dd MMM, HH:mm') : '—' }}
+
+
diff --git a/src/app/interest-posting-summary/interest-posting-summary.component.ts b/src/app/interest-posting-summary/interest-posting-summary.component.ts new file mode 100644 index 000000000..de973d7ac --- /dev/null +++ b/src/app/interest-posting-summary/interest-posting-summary.component.ts @@ -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(); + 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}`; + } +} diff --git a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts index 9ab9b9b5d..51336d7c2 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts @@ -34,6 +34,7 @@ import { ageInFy, buildSchedule, checkEligibility, + configuredFys, declarationFormFor, formKindFor, isSeniorInFy, @@ -134,7 +135,10 @@ export class Form15ghComponent implements OnInit { // Derived once. The template reads these many times per change-detection // pass, and re-running the schedules on every read is what made the other // register screens stall. - this.rows = seedForm15gh().map((record) => this.buildRow(record)); + // Only years that are configured can be computed; the engine throws on any other. + this.rows = seedForm15gh() + .filter((record) => !!FY_TAX_CONFIG[normaliseFy(record.fy)]) + .map((record) => this.buildRow(record)); } private buildRow(record: Form15ghRecord): Form15ghRow { @@ -219,13 +223,13 @@ export class Form15ghComponent implements OnInit { * than written into the template. */ get statute(): { form: string; section: string; statement: string; frequency: string } { - const fy = normaliseFy(this.rows[0]?.record.fy ?? '2026-27'); + const fy = normaliseFy(this.rows[0]?.record.fy ?? configuredFys()[0] ?? ''); const cfg = FY_TAX_CONFIG[fy]; return { - form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H', - section: cfg.interestTdsSection, - statement: cfg.tdsStatementForm, - frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly' + form: cfg?.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H', + section: cfg?.interestTdsSection ?? '—', + statement: cfg?.tdsStatementForm ?? '—', + frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly' }; } diff --git a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts index dfac843d7..fca67d7bc 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts @@ -41,6 +41,7 @@ import { buildSchedule, checkEligibility, collectTdsCredits, + configuredFys, declarationFormFor, formKindFor, isSeniorInFy, @@ -115,18 +116,15 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy { /** Which row's quarter-wise breakdown is open. */ expandedId: string | null = null; - selectedFy = 'FY 2026-27'; + /** Every year configured on Organization › TDS Configuration, loaded by the route resolver. */ + readonly availableFys = configuredFys().map((fy) => `FY ${fy}`); + selectedFy = this.availableFys[0] ?? ''; // Per-client caches private clientDataCache = new Map>(); private clientKycCache = new Map>(); private clientAccountsCache = new Map>(); - readonly availableFys = [ - 'FY 2026-27', - 'FY 2025-26' - ]; - ngOnInit(): void { this.loadRealData(); } @@ -137,6 +135,12 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy { } loadRealData(): void { + // No configured year means no rates to compute with; report it rather than throw mid-build. + if (!this.selectedFy) { + this.loading = false; + this.loadError = true; + return; + } this.loading = true; this.loadError = false; this.clientDataCache.clear(); diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts index 636979647..502e3f5b0 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts @@ -30,6 +30,9 @@ import { tdsDeducteeOf, tdsModeFor } from './tds-15gh.compute'; +import { installTdsConfigFixture } from './tds-config.fixture'; + +installTdsConfigFixture(); const FY = 'FY 2026-27'; diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts index 9cba8c989..870e75dc7 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts @@ -129,73 +129,37 @@ export interface FyTaxConfig { } /** - * Statutory figures for the years this book covers, on the new tax regime. + * Statutory figures per financial year, keyed '2026-27'. + * + * Nothing is compiled in. The values live in m_tds_fy_config, are edited on + * Organization › TDS Configuration, and are loaded into this registry by + * `TdsConfigService` before any TDS screen opens (see `tdsConfigResolver`). A + * year with no entry makes the computation throw rather than guess. * * For a customer who has opted out onto the old regime, `basicExemption` and * `nilTaxCeiling` are lower and vary by age; pass an override through * `TdsContext.config`. - * - * Thresholds and rates did not change at the 2026 changeover — ₹50,000, - * ₹1,00,000 for senior citizens, 10%, and 20% without PAN carried straight - * across. What changed is the declaration and the return it is reported in. */ -export const FY_TAX_CONFIG: Record = { - '2025-26': { - fy: '2025-26', - regime: 'ITA_1961', - interestTdsSection: 'section 194A', - thresholdSection: 'section 194A(3)(i)', - noPanSection: 'section 206AA', - defaultSection: 'section 201(1A)', - declarationForm: '15G', - declarationRule: 'Rule 29C', - declarationSection: 'section 197A', - tdsStatementForm: 'Form 26Q', - tdsStatementRule: 'Rule 31A', - certificateForm: 'Form 16A', - declarationStatementFrequency: 'QUARTERLY', - declarationStatementDueDay: 15, - thresholdGeneral: 50_000, - thresholdSenior: 100_000, - rate: 0.1, - rateNoPan: 0.2, - basicExemption: 400_000, - nilTaxCeiling: 1_200_000, - roundTo: 1, - lateDeductionRate: 0.01, - lateDepositRate: 0.015 - }, - '2026-27': { - fy: '2026-27', - regime: 'ITA_2025', - // Old section 194A was folded into the omnibus TDS section of the 2025 Act. - interestTdsSection: 'section 393(1) [Table Sl. 5(ii)/(iii)]', - thresholdSection: 'section 393(4) [Table Sl. 7]', - // 206AA and 206CC merged here. The reduced 5% limb applies only to Sl. 8(ii) - // and 8(v), so interest stays at 20% without PAN. - noPanSection: 'section 397(2)', - defaultSection: 'section 398', - declarationForm: '121', - declarationRule: 'Rule 211 of the Income-tax Rules, 2026', - declarationSection: 'section 393(6)', - tdsStatementForm: 'Form 140', - tdsStatementRule: 'Rule 219 of the Income-tax Rules, 2026', - certificateForm: 'Form 131', - // The consolidated statement of declarations received moved from a quarterly - // upload to a monthly one, due on the 7th of the following month. - declarationStatementFrequency: 'MONTHLY', - declarationStatementDueDay: 7, - thresholdGeneral: 50_000, - thresholdSenior: 100_000, - rate: 0.1, - rateNoPan: 0.2, - basicExemption: 400_000, - nilTaxCeiling: 1_200_000, - roundTo: 1, - lateDeductionRate: 0.01, - lateDepositRate: 0.015 +export const FY_TAX_CONFIG: Record = {}; + +/** Bumped on every load, so memoised computations can tell one configuration from the next. */ +export let fyTaxConfigVersion = 0; + +/** Replaces the whole registry with what the platform holds. */ +export function setFyTaxConfigs(configs: FyTaxConfig[]): void { + for (const key of Object.keys(FY_TAX_CONFIG)) { + delete FY_TAX_CONFIG[key]; } -}; + for (const cfg of configs || []) { + FY_TAX_CONFIG[normaliseFy(cfg.fy)] = cfg; + } + fyTaxConfigVersion++; +} + +/** The configured years, latest first. */ +export function configuredFys(): string[] { + return Object.keys(FY_TAX_CONFIG).sort().reverse(); +} // ─────────────────────── Unique Identification Number ─────────────────────── diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-config.fixture.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-config.fixture.ts new file mode 100644 index 000000000..fa3282d83 --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-config.fixture.ts @@ -0,0 +1,71 @@ +/** + * 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/. + */ + +/** + * Test-only TDS configuration. The application reads these figures from Organization › TDS + * Configuration; the specs install this copy of the seeded values so they run without a backend. + */ + +import { FyTaxConfig, setFyTaxConfigs } from './tds-15gh.compute'; + +export const TDS_CONFIG_FIXTURE: FyTaxConfig[] = [ + { + fy: '2025-26', + regime: 'ITA_1961', + interestTdsSection: 'section 194A', + thresholdSection: 'section 194A(3)(i)', + noPanSection: 'section 206AA', + defaultSection: 'section 201(1A)', + declarationForm: '15G', + declarationRule: 'Rule 29C', + declarationSection: 'section 197A', + tdsStatementForm: 'Form 26Q', + tdsStatementRule: 'Rule 31A', + certificateForm: 'Form 16A', + declarationStatementFrequency: 'QUARTERLY', + declarationStatementDueDay: 15, + thresholdGeneral: 50_000, + thresholdSenior: 100_000, + rate: 0.1, + rateNoPan: 0.2, + basicExemption: 400_000, + nilTaxCeiling: 1_200_000, + roundTo: 1, + lateDeductionRate: 0.01, + lateDepositRate: 0.015 + }, + { + fy: '2026-27', + regime: 'ITA_2025', + interestTdsSection: 'section 393(1) [Table Sl. 5(ii)/(iii)]', + thresholdSection: 'section 393(4) [Table Sl. 7]', + noPanSection: 'section 397(2)', + defaultSection: 'section 398', + declarationForm: '121', + declarationRule: 'Rule 211 of the Income-tax Rules, 2026', + declarationSection: 'section 393(6)', + tdsStatementForm: 'Form 140', + tdsStatementRule: 'Rule 219 of the Income-tax Rules, 2026', + certificateForm: 'Form 131', + declarationStatementFrequency: 'MONTHLY', + declarationStatementDueDay: 7, + thresholdGeneral: 50_000, + thresholdSenior: 100_000, + rate: 0.1, + rateNoPan: 0.2, + basicExemption: 400_000, + nilTaxCeiling: 1_200_000, + roundTo: 1, + lateDeductionRate: 0.01, + lateDepositRate: 0.015 + } +]; + +export function installTdsConfigFixture(): void { + setFyTaxConfigs(TDS_CONFIG_FIXTURE); +} diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-config.service.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-config.service.ts new file mode 100644 index 000000000..26771a849 --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-config.service.ts @@ -0,0 +1,150 @@ +/** + * 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 } from '@angular/common/http'; +import { ResolveFn } from '@angular/router'; +import { Observable, catchError, map, of, tap } from 'rxjs'; + +import { FyTaxConfig, setFyTaxConfigs } from './tds-15gh.compute'; + +/** One financial year as the platform stores it — percentages as entered, e.g. 10 for 10%. */ +export interface TdsFyConfigRecord { + id?: number; + fy: string; + regime: string; + interestTdsSection: string | null; + thresholdSection: string | null; + noPanSection: string | null; + defaultSection: string | null; + declarationForm: string; + declarationRule: string | null; + declarationSection: string | null; + tdsStatementForm: string | null; + tdsStatementRule: string | null; + certificateForm: string | null; + declarationStatementFrequency: string; + declarationStatementDueDay: number; + thresholdGeneral: number; + thresholdSenior: number; + ratePanPercentage: number; + rateNoPanPercentage: number; + basicExemption: number; + nilTaxCeiling: number; + roundTo: number; + lateDeductionPercentage: number; + lateDepositPercentage: number; + seedDefault?: boolean; + updatedOn?: string | null; +} + +export interface TdsGlRole { + configKey: string; + label: string; + description: string; + glAccountId: number | null; + glCode: string | null; + glAccountName: string | null; +} + +export interface TdsConfigResponse { + years: TdsFyConfigRecord[]; + glRoles: TdsGlRole[]; +} + +/** + * The single source of TDS thresholds, rates and statutory references. + * + * Every TDS screen computes from `FY_TAX_CONFIG`, and this service is the only thing that fills it. + * Saves go through here too, so the registry is refreshed the moment an administrator changes a + * figure and no open screen keeps computing on the old one. + */ +@Injectable({ providedIn: 'root' }) +export class TdsConfigService { + private http = inject(HttpClient); + + private last: TdsConfigResponse | null = null; + + /** The GL roles as last loaded. */ + get glRoles(): TdsGlRole[] { + return this.last?.glRoles ?? []; + } + + /** Reads the configuration and installs it into the engine. */ + load(): Observable { + return this.http.get('/tdsconfig').pipe(tap((res) => this.install(res))); + } + + /** For route resolvers: never blocks navigation, the screen reports a missing year itself. */ + ensureLoaded(): Observable { + return this.load().pipe( + map(() => true), + catchError(() => { + setFyTaxConfigs([]); + return of(false); + }) + ); + } + + template(): Observable { + return this.http.get('/tdsconfig/template'); + } + + saveYear(record: TdsFyConfigRecord): Observable { + return this.http + .put(`/tdsconfig/year/${record.fy}`, record) + .pipe(tap((res) => this.install(res))); + } + + deleteYear(fy: string): Observable { + return this.http.delete(`/tdsconfig/year/${fy}`).pipe(tap((res) => this.install(res))); + } + + saveGl(configKey: string, glAccountId: number | null): Observable { + return this.http + .put(`/tdsconfig/gl/${configKey}`, { glAccountId }) + .pipe(tap((res) => this.install(res))); + } + + private install(res: TdsConfigResponse): void { + this.last = res; + setFyTaxConfigs((res?.years || []).map(toFyTaxConfig)); + } +} + +/** Stored percentages become the fractions the engine multiplies by. */ +export function toFyTaxConfig(r: TdsFyConfigRecord): FyTaxConfig { + return { + fy: r.fy, + regime: r.regime as FyTaxConfig['regime'], + interestTdsSection: r.interestTdsSection ?? '—', + thresholdSection: r.thresholdSection ?? '—', + noPanSection: r.noPanSection ?? '—', + defaultSection: r.defaultSection ?? '—', + declarationForm: r.declarationForm as FyTaxConfig['declarationForm'], + declarationRule: r.declarationRule ?? '—', + declarationSection: r.declarationSection ?? '—', + tdsStatementForm: r.tdsStatementForm ?? '—', + tdsStatementRule: r.tdsStatementRule ?? '—', + certificateForm: r.certificateForm ?? '—', + declarationStatementFrequency: r.declarationStatementFrequency === 'MONTHLY' ? 'MONTHLY' : 'QUARTERLY', + declarationStatementDueDay: Number(r.declarationStatementDueDay), + thresholdGeneral: Number(r.thresholdGeneral), + thresholdSenior: Number(r.thresholdSenior), + rate: Number(r.ratePanPercentage) / 100, + rateNoPan: Number(r.rateNoPanPercentage) / 100, + basicExemption: Number(r.basicExemption), + nilTaxCeiling: Number(r.nilTaxCeiling), + roundTo: Number(r.roundTo) || 1, + lateDeductionRate: Number(r.lateDeductionPercentage) / 100, + lateDepositRate: Number(r.lateDepositPercentage) / 100 + }; +} + +/** Put on every route whose screen computes TDS, so the figures are in place before it builds. */ +export const tdsConfigResolver: ResolveFn = () => inject(TdsConfigService).ensureLoaded(); diff --git a/src/app/letter-mgmt/letter-mgmt-routing.module.ts b/src/app/letter-mgmt/letter-mgmt-routing.module.ts index c0909eb59..c87d6a202 100644 --- a/src/app/letter-mgmt/letter-mgmt-routing.module.ts +++ b/src/app/letter-mgmt/letter-mgmt-routing.module.ts @@ -16,6 +16,7 @@ import { ShareCertificateComponent } from './certificates/share-certificate/shar import { BalanceCertificateComponent } from './certificates/balance-certificate/balance-certificate.component'; import { Form60Component } from './certificates/form-60/form-60.component'; import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component'; +import { tdsConfigResolver } from './certificates/form-15gh/tds-config.service'; import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component'; import { NocCertificateComponent } from './certificates/noc/noc.component'; import { DatabaseExportComponent } from './database-export/database-export.component'; @@ -68,6 +69,7 @@ const routes: Routes = [ { path: 'letter-mgmt/form-15gh', component: Form15ghComponent, + resolve: { tdsConfig: tdsConfigResolver }, data: { title: 'Form 121', breadcrumb: 'Form 121' } }, { diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts index 4bf3298d2..e69f7b89b 100644 --- a/src/app/login/login.component.ts +++ b/src/app/login/login.component.ts @@ -438,7 +438,7 @@ export class LoginComponent implements OnInit, OnDestroy { .split(',') .map((id) => id.trim()) .filter((id) => id.length > 0); - if (tenantIds.length === 0 || (tenantIds.length === 1 && tenantIds[0] === 'default')) { + if (tenantIds.length === 0) { return false; } return true; diff --git a/src/app/organization/branch-profile/branch-profile.component.ts b/src/app/organization/branch-profile/branch-profile.component.ts index 628d0cb6b..564d8edd1 100644 --- a/src/app/organization/branch-profile/branch-profile.component.ts +++ b/src/app/organization/branch-profile/branch-profile.component.ts @@ -143,11 +143,25 @@ export class BranchProfileComponent implements OnInit { .subscribe((res: any) => { this.saving = false; if (!res) return; + if (res.rollbackTransaction) { + // Parked for checker approval - the branch profile has not actually changed yet. + this.successMsg = 'Branch profile update has been submitted for approval.'; + this.form.markAsPristine(); + return; + } this.successMsg = 'Branch profile saved.'; this.form.markAsPristine(); - this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null; - const idx = this.offices.findIndex((o) => o.officeId === this.selectedOfficeId); - if (idx >= 0) this.offices[idx] = res; + // The response is now a CommandProcessingResult, not the updated row (the write goes through + // the command pipeline) - reload the saved row from the server instead of patching state from + // this response. + this.organizationService.getBranchProfile(this.selectedOfficeId!).subscribe((updated: any) => { + if (!updated) return; + this.lastUpdated = updated.updatedByUsername + ? { by: updated.updatedByUsername, at: updated.updatedAt } + : null; + const idx = this.offices.findIndex((o) => o.officeId === this.selectedOfficeId); + if (idx >= 0) this.offices[idx] = updated; + }); }); } } diff --git a/src/app/organization/currencies/manage-currencies/manage-currencies.component.ts b/src/app/organization/currencies/manage-currencies/manage-currencies.component.ts index a1564f3b9..f34083525 100644 --- a/src/app/organization/currencies/manage-currencies/manage-currencies.component.ts +++ b/src/app/organization/currencies/manage-currencies/manage-currencies.component.ts @@ -42,6 +42,7 @@ import { AsyncPipe } from '@angular/common'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatGridList, MatGridTile } from '@angular/material/grid-list'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Manage Currencies component. @@ -68,6 +69,7 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr private translateService = inject(TranslateService); private configurationWizardService = inject(ConfigurationWizardService); private popoverService = inject(PopoverService); + private alertService = inject(AlertService); //** Defining PlaceHolders for the search bar */ placeHolderLabel = ''; @@ -164,8 +166,17 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr if (!selectedCurrencyCodes.includes(newCurrency.code)) { selectedCurrencyCodes.push(newCurrency.code); this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe((response: any) => { - this.selectedCurrencies.push(newCurrency); this.formRef.resetForm(); + if (response?.rollbackTransaction) { + // Parked for checker approval - the currency has not actually been added yet, so the + // local list must not be mutated as if it had. + this.alertService.alert({ + type: 'Success', + message: 'Currency configuration update has been submitted for approval.' + }); + return; + } + this.selectedCurrencies.push(newCurrency); if (this.configurationWizardService.showCurrencyForm) { this.configurationWizardService.showCurrencyForm = false; this.openDialog(); @@ -187,9 +198,18 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr }); deleteCurrencyDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe(() => { - this.selectedCurrencies.splice(index, 1); + this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe((updateResponse: any) => { this.formRef.resetForm(); + if (updateResponse?.rollbackTransaction) { + // Parked for checker approval - the currency has not actually been removed yet, so the + // local list must not be mutated as if it had. + this.alertService.alert({ + type: 'Success', + message: 'Currency configuration update has been submitted for approval.' + }); + return; + } + this.selectedCurrencies.splice(index, 1); }); } }); diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts index 2d04f9480..db05e8a75 100644 --- a/src/app/organization/institution-profile/institution-profile.component.ts +++ b/src/app/organization/institution-profile/institution-profile.component.ts @@ -182,9 +182,18 @@ export class InstitutionProfileComponent implements OnInit { .subscribe((res: any) => { this.saving = false; if (!res) return; + if (res.rollbackTransaction) { + // Parked for checker approval - the profile has not actually changed yet, so the form must + // not be patched with the submitted (not-yet-applied) values as if it had. + this.successMsg = 'Bank profile update has been submitted for approval.'; + this.form.markAsPristine(); + return; + } this.successMsg = 'Bank profile saved.'; - this.applyResult(res); - this.form.markAsPristine(); + // The response is now a CommandProcessingResult, not the updated row (the write goes through + // the command pipeline) - reload the saved row from the server instead of patching the form + // from this response. + this.load(); }); } diff --git a/src/app/organization/manage-funds/create-fund/create-fund.component.ts b/src/app/organization/manage-funds/create-fund/create-fund.component.ts index 4d2c0de59..292d6c132 100644 --- a/src/app/organization/manage-funds/create-fund/create-fund.component.ts +++ b/src/app/organization/manage-funds/create-fund/create-fund.component.ts @@ -11,6 +11,7 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { OrganizationService } from 'app/organization/organization.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; @Component({ selector: 'mifosx-create-fund', @@ -25,6 +26,7 @@ export class CreateFundComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private router = inject(Router); private route = inject(ActivatedRoute); + private alertService = inject(AlertService); /** Charge form. */ fundForm: UntypedFormGroup; @@ -49,6 +51,12 @@ export class CreateFundComponent implements OnInit { submit() { const payload = this.fundForm.getRawValue(); this.organizationService.createFund(payload).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Fund creation has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 0c35f6ceb..50723ef0e 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -261,6 +261,32 @@ const routes: Routes = [ import('./clearing-gl-config/clearing-gl-config.component').then((m) => m.ClearingGlConfigComponent), data: { title: 'Clearing GL Configuration', breadcrumb: 'Clearing GL Configuration' } }, + { + // CRR/SLR percentages and the GL accounts RBI Form 1 and Form 9 read their figures from. + path: 'rbi-reserve-config', + loadComponent: () => + import('./rbi-reserve-config/rbi-reserve-config.component').then((m) => m.RbiReserveConfigComponent), + data: { title: 'RBI Reserve Configuration', breadcrumb: 'RBI Reserve Configuration' } + }, + { + // TDS thresholds, rates and statutory references per FY, and the TDS payable GL. + path: 'tds-config', + loadComponent: () => import('./tds-config/tds-config.component').then((m) => m.TdsConfigComponent), + data: { title: 'TDS Configuration', breadcrumb: 'TDS Configuration' } + }, + { + // OSS 1 keeps its own screen: sixteen roles of its own, none shared with CRR or SLR. + path: 'oss1-gl-config', + loadComponent: () => + import('./rbi-reserve-config/oss1-gl-config.component').then((m) => m.Oss1GlConfigComponent), + data: { title: 'OSS 1 GL Configuration', breadcrumb: 'OSS 1 GL Configuration' } + }, + { + path: 'oss2-gl-config', + loadComponent: () => + import('./rbi-reserve-config/oss2-gl-config.component').then((m) => m.Oss2GlConfigComponent), + data: { title: 'OSS 2 GL Configuration', breadcrumb: 'OSS 2 GL Configuration' } + }, { path: 'employees', data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' }, diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index 4bf1aad35..6531fe8b2 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -76,6 +76,64 @@
+ +
+ +
+ +
+
+

RBI Reserve Configuration

+

CRR and SLR rates, and the GL accounts behind Form 1 and Form 9

+
+ +
+
+ + +
+ +
+ +
+
+

TDS Configuration

+

TDS thresholds and rates per financial year, and the TDS payable GL

+
+ +
+
+ + +
+ +
+ +
+
+

OSS 1 GL Configuration

+

+ GL accounts behind OSS 1 — Return on Assets and Liabilities, read as closing balances +

+
+ +
+
+ + +
+ +
+ +
+
+

OSS 2 GL Configuration

+

GL accounts behind OSS 2 — Return on Earnings, read as quarterly movement

+
+ +
+
+
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index f75312831..5090e143b 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -935,4 +935,71 @@ export class OrganizationService { updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable { return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId }); } + + // ── RBI Reserve Configuration (Form 1 CRR / Form 9 SLR) ── + + /** + * @returns {Observable} `{ rates[], glRoles[] }` — the effective-dated CRR/SLR percentages + * and the GL accounts mapped to each reserve role. Roles with nothing mapped are included, so + * the screen can show the gaps rather than hide them. + */ + getRbiReserveConfig(): Observable { + return this.http.get('/rbireserveconfig'); + } + + /** @returns {Observable} Selectable (postable, non-header) GL accounts plus the rate types. */ + getRbiReserveConfigTemplate(): Observable { + return this.http.get('/rbireserveconfig/template'); + } + + /** + * Replaces the entire set of GL accounts mapped to one reserve role. + * + * Each mapping carries the portion of the account's balance the role takes and, on a liability + * head, whether that portion is a demand or a time liability. RBI treats part of a savings balance + * as demand and the rest as time, so the same account is sent twice under one role with different + * classes - which is why this is a list of mappings rather than a list of account ids. + * + * @param {string} configKey A built-in role (CASH_IN_HAND, BALANCE_WITH_OTHER_BANKS, + * DEMAND_TIME_LIABILITIES, SLR_INVESTMENTS) or the key of a head an administrator added. + */ + updateRbiReserveGlRole( + configKey: string, + mappings: { glAccountId: number; weightPercentage: number; liabilityClass: string | null }[] + ): Observable { + return this.http.put(`/rbireserveconfig/gl/${configKey}`, { mappings, locale: 'en' }); + } + + /** + * Adds a head to a return - a reserve, a tax payable, anything the bank's chart carries that the + * built-in roles do not name. `usage` states how the return consumes it: LIABILITY, ASSET_NET, + * MAINTAINED or MEMO. + */ + createRbiReserveRole(role: { label: string; description: string; group: string; usage: string }): Observable { + return this.http.post('/rbireserveconfig/role', { ...role, locale: 'en' }); + } + + /** Removes an administrator-defined head and the accounts pinned to it. */ + deleteRbiReserveRole(roleId: number): Observable { + return this.http.delete(`/rbireserveconfig/role/${roleId}`); + } + + /** + * Sets a CRR or SLR rate with effect from a date. Restating an existing effective date edits that + * row; a new date adds one, so past returns keep the rate that was actually in force. + */ + upsertRbiReserveRate(rateType: string, percentage: number, effectiveFrom: string): Observable { + return this.http.post('/rbireserveconfig/rate', { + rateType, + percentage, + effectiveFrom, + locale: 'en', + dateFormat: 'yyyy-MM-dd' + }); + } + + /** @returns {Observable} */ + deleteRbiReserveRate(rateId: number): Observable { + return this.http.delete(`/rbireserveconfig/rate/${rateId}`); + } } diff --git a/src/app/organization/payment-types/create-payment-type/create-payment-type.component.ts b/src/app/organization/payment-types/create-payment-type/create-payment-type.component.ts index d16092a72..c3b0d26b6 100644 --- a/src/app/organization/payment-types/create-payment-type/create-payment-type.component.ts +++ b/src/app/organization/payment-types/create-payment-type/create-payment-type.component.ts @@ -16,6 +16,7 @@ import { OrganizationService } from '../../organization.service'; import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { MatCheckbox } from '@angular/material/checkbox'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Create Payment Type Component. @@ -35,6 +36,7 @@ export class CreatePaymentTypeComponent implements OnInit { private organizationService = inject(OrganizationService); private router = inject(Router); private route = inject(ActivatedRoute); + private alertService = inject(AlertService); /** Payment Type form. */ paymentTypeForm: UntypedFormGroup; @@ -73,7 +75,13 @@ export class CreatePaymentTypeComponent implements OnInit { */ submit() { const paymentType = this.paymentTypeForm.value; - this.organizationService.createPaymentType(paymentType).subscribe((response) => { + this.organizationService.createPaymentType(paymentType).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Payment Type creation has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts b/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts index 7ec31a1c2..81eda5756 100644 --- a/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts +++ b/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts @@ -16,6 +16,7 @@ import { OrganizationService } from 'app/organization/organization.service'; import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { MatCheckbox } from '@angular/material/checkbox'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit Payment Type component. @@ -35,6 +36,7 @@ export class EditPaymentTypeComponent implements OnInit { private organizationService = inject(OrganizationService); private router = inject(Router); private route = inject(ActivatedRoute); + private alertService = inject(AlertService); /** Payment Type form. */ paymentTypeForm: UntypedFormGroup; @@ -88,7 +90,13 @@ export class EditPaymentTypeComponent implements OnInit { */ submit() { const paymentType = this.paymentTypeForm.value; - this.organizationService.updatePaymentType(this.paymentTypeData.id, paymentType).subscribe((response) => { + this.organizationService.updatePaymentType(this.paymentTypeData.id, paymentType).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Payment Type update has been submitted for approval.' + }); + } this.router.navigate(['../../'], { relativeTo: this.route }); }); } diff --git a/src/app/organization/payment-types/payment-types.component.ts b/src/app/organization/payment-types/payment-types.component.ts index d883877e1..189f7efa5 100644 --- a/src/app/organization/payment-types/payment-types.component.ts +++ b/src/app/organization/payment-types/payment-types.component.ts @@ -34,6 +34,7 @@ import { DeleteDialogComponent } from '../../shared/delete-dialog/delete-dialog. import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Payment Types component. @@ -65,6 +66,7 @@ export class PaymentTypesComponent implements OnInit { private organizationService = inject(OrganizationService); private route = inject(ActivatedRoute); private dialog = inject(MatDialog); + private alertService = inject(AlertService); /** Payment Types data. */ paymentTypesData: any; @@ -132,9 +134,20 @@ export class PaymentTypesComponent implements OnInit { }); deletePaymentTypeDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.organizationService.deletePaymentType(paymentTypeId).subscribe(() => { - this.paymentTypesData = this.paymentTypesData.filter((paymentType: any) => paymentType.id !== paymentTypeId); - this.setPaymentTypes(); + this.organizationService.deletePaymentType(paymentTypeId).subscribe((deleteResponse: any) => { + if (deleteResponse?.rollbackTransaction) { + // Parked for checker approval - the record has not actually been removed yet, so the + // list must not drop it (that would falsely show the deletion as already applied). + this.alertService.alert({ + type: 'Success', + message: 'Payment Type deletion has been submitted for approval.' + }); + } else { + this.paymentTypesData = this.paymentTypesData.filter( + (paymentType: any) => paymentType.id !== paymentTypeId + ); + this.setPaymentTypes(); + } }); } }); diff --git a/src/app/organization/rbi-reserve-config/oss1-gl-config.component.ts b/src/app/organization/rbi-reserve-config/oss1-gl-config.component.ts new file mode 100644 index 000000000..5767c2047 --- /dev/null +++ b/src/app/organization/rbi-reserve-config/oss1-gl-config.component.ts @@ -0,0 +1,47 @@ +/** + * 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 } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RbiReserveConfigComponent } from './rbi-reserve-config.component'; + +/** + * GL configuration for OSS 1 — Return on Assets and Liabilities. + * + * Its own screen rather than a section of the reserve configuration: OSS 1 has sixteen roles of its + * own and shares none of them with CRR or SLR, so the two are separate decisions made at separate + * times. The mapping mechanics are identical, so only the scope and the wording differ. + * + * There are no rates here. OSS 1 states a position; it is not a percentage charged on a base. + */ +@Component({ + selector: 'mifosx-oss1-gl-config', + templateUrl: './rbi-reserve-config.component.html', + styleUrls: ['./rbi-reserve-config.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class Oss1GlConfigComponent extends RbiReserveConfigComponent { + protected override readonly group = 'OSS1'; + protected override readonly showRates = false; + + override get screenTitle(): string { + return 'OSS 1 GL Configuration'; + } + + override get screenSubtitle(): string { + return ( + 'The GL accounts OSS 1 — Return on Assets and Liabilities reads its figures from. Each head takes the closing ' + + 'balance of its accounts as at the quarter end. A head left unmapped reports nothing rather than a nil figure ' + + 'that would read as a real position of zero.' + ); + } +} diff --git a/src/app/organization/rbi-reserve-config/oss2-gl-config.component.ts b/src/app/organization/rbi-reserve-config/oss2-gl-config.component.ts new file mode 100644 index 000000000..e6df3064c --- /dev/null +++ b/src/app/organization/rbi-reserve-config/oss2-gl-config.component.ts @@ -0,0 +1,46 @@ +/** + * 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 } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RbiReserveConfigComponent } from './rbi-reserve-config.component'; + +/** + * GL configuration for OSS 2 — Return on Earnings. + * + * Separate from OSS 1 because the two read their accounts differently, not only because they are + * different returns: OSS 1 takes closing balances, OSS 2 takes the movement over the quarter. The + * same account mapped to both would be right on one and wrong on the other, so the screens are kept + * apart rather than sharing a single list of accounts. + */ +@Component({ + selector: 'mifosx-oss2-gl-config', + templateUrl: './rbi-reserve-config.component.html', + styleUrls: ['./rbi-reserve-config.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class Oss2GlConfigComponent extends RbiReserveConfigComponent { + protected override readonly group = 'OSS2'; + protected override readonly showRates = false; + + override get screenTitle(): string { + return 'OSS 2 GL Configuration'; + } + + override get screenSubtitle(): string { + return ( + 'The GL accounts OSS 2 — Return on Earnings reads its figures from. Each item takes the movement over the ' + + 'reporting quarter, not a closing balance: an earnings line reports what was earned or spent in the period, so ' + + 'the same accounts read as balances would carry forward every earlier quarter as well.' + ); + } +} diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html new file mode 100644 index 000000000..73da6196b --- /dev/null +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html @@ -0,0 +1,373 @@ + + +
+
+ +

{{ screenTitle }}

+
+

{{ screenSubtitle }}

+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (loading) { +
Loading…
+ } @else { + + @if (showRates) { +
+

Reserve rates

+

+ Rates are effective-dated rather than overwritten: a return filed for an earlier period keeps reporting the + rate that was actually in force then. Saving against a date that already has a rate edits it; a new date adds + one. +

+ +
+ @for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) { +
+ + {{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today + + @if (currentRate(type); as rate) { + {{ rate.percentage }}% + @if (rate.seedDefault) { + + Placeholder — confirm the rate actually in force + + } @else { + since {{ rate.effectiveFrom }} + } + } @else { + Not set + {{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute + } +
+ } +
+ +
+ + Rate + + + CRR — Form 1 Part B + SLR — Form 1 Part C + + Savings — demand share + + + + + Percentage (%) + + + + + Effective from + + + + +
+ + + + + + + + + + + + + @for (rate of rates; track rate.id) { + + + + + + + + } + +
RatePercentageEffective fromStatus
+ {{ rate.rateType }} + {{ rate.percentage }}%{{ rate.effectiveFrom }} + @if (isFuture(rate)) { + Future-dated + } @else if (currentRate(rate.rateType)?.id === rate.id) { + In force + } @else { + Superseded + } + @if (rate.seedDefault) { + Placeholder + } + + +
+
+ } + + +
+
+

GL accounts behind each figure

+ + + Configuring + + @for (f of formFilters; track f.value) { + {{ f.label }} + } + + +
+

+ 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. + } +

+ + @if (unmappedCount) { +
+ {{ unmappedCount }} of {{ glRoles.length }} roles have no accounts mapped. Returns that depend on them will + report why they cannot compute instead of showing a figure. +
+ } + + + @if (showTabs) { +
+ @for (group of roleGroups; track group.name) { + + } +
+ } + + @for (role of visibleRoles; track role.configKey) { +
+
+
+ {{ roleLabel(role) }} + {{ role.configKey }} + {{ roleUsedBy(role) }} +
+ @if (isUnmapped(role)) { + Not mapped + } @else { + + {{ selection[role.configKey].length }} account{{ selection[role.configKey].length === 1 ? '' : 's' }} + + } +
+ +

{{ role.description }}

+ + + @if (selection[role.configKey]?.length) { + + + + + + @if (role.classified) { + + } + + + + + @for (mapping of selection[role.configKey]; track $index; let i = $index) { + + + + @if (role.classified) { + + } + + + } + +
GL accountPortion (%)Head
+ + + @for (o of availableAccounts(role, i); track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + + + + + + + + + + @for (c of liabilityClasses; track c.value) { + {{ c.label }} + } + + + + +
+ } + + + @for (entry of unbalancedAccounts(role); track entry.glAccountId) { +
+ {{ glLabel(entry.glAccountId) }} is mapped at {{ entry.total }}% in total. + @if (entry.total < 100) { + {{ 100 - entry.total }}% of its balance will not appear on the return. + } @else { + It would be counted {{ entry.total }}% — more than the account holds. + } +
+ } + +
+ + + + + @if (!role.builtIn) { + + } +
+
+ } + + +
+ @if (!showAddRole) { + +

+ For anything this bank carries that the heads above do not name — a reserve, a tax payable, bills payable. + The return reads it from the same chart of accounts the balance sheet is built from. +

+ } @else { +
+ + Name + + + + + How the return uses it + + @for (u of roleUsages; track u.value) { + {{ u.label }} + } + + + + + Note (optional) + + + + +

+ Naming a head Memorandum - …, Annexure A - … or + Annexure B - … prints it under that section of the return instead of in the main + statement. +

+ + + +
+ } +
+
+ } +
diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss new file mode 100644 index 000000000..8d62d74da --- /dev/null +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss @@ -0,0 +1,495 @@ +/** + * 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; + +.rrc-container { + max-width: 1040px; + margin: 0 auto; + padding: 20px 16px; +} + +.rrc-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 6px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.rrc-sub { + margin: 0 0 16px; + font-size: 12px; + color: $gray-500; + max-width: 820px; + line-height: 1.6; +} + +.rrc-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.rrc-alert { + padding: 10px 14px; + border-radius: 8px; + font-size: 12px; + margin-bottom: 14px; + + &--error { + background: #fce8e6; + color: $red; + } + + &--success { + background: #e6f4ea; + color: $green; + } + + &--warn { + background: #fef7e0; + color: $amber; + } +} + +.rrc-loading { + padding: 28px; + text-align: center; + color: $gray-500; + font-size: 13px; +} + +.rrc-card { + background: #fff; + border: 1px solid $border; + border-radius: 12px; + padding: 18px; + margin-bottom: 18px; + + &__title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 600; + color: $gray-900; + } + + &__hint { + margin: 0 0 16px; + font-size: 12px; + color: $gray-500; + line-height: 1.6; + } +} + +// ── Rates ── + +.rrc-rate-now { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 18px; + + &__item { + flex: 1 1 220px; + background: $gray-50; + border: 1px solid $border; + border-radius: 10px; + padding: 12px 14px; + } + + &__label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + margin-bottom: 4px; + } + + &__value { + font-size: 22px; + font-weight: 700; + color: $primary; + + &--none { + font-size: 15px; + color: $red; + } + } + + &__since { + display: block; + margin-top: 2px; + font-size: 11px; + color: $gray-500; + + &--warn { + color: $amber; + font-weight: 600; + } + } +} + +.rrc-rate-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-bottom: 18px; + + &__type { + flex: 0 1 190px; + } + + &__pct { + flex: 0 1 150px; + } + + &__date { + flex: 0 1 190px; + } +} + +// ── Tables ── + +.rrc-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + font-weight: 600; + color: $gray-500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 8px 10px; + border-bottom: 1px solid $border; + } + + td { + padding: 9px 10px; + border-bottom: 1px solid $gray-200; + color: $gray-900; + vertical-align: middle; + } + + tr:last-child td { + border-bottom: none; + } +} + +.rrc-num { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.rrc-key { + display: inline-block; + font-family: 'Roboto Mono', monospace; + font-size: 10.5px; + background: $gray-50; + border: 1px solid $border; + border-radius: 5px; + padding: 2px 6px; + color: $gray-500; +} + +.rrc-badge { + display: inline-block; + font-size: 10.5px; + font-weight: 600; + border-radius: 20px; + padding: 3px 10px; + white-space: nowrap; + + &--active, + &--mapped { + background: #e6f4ea; + color: $green; + } + + &--future { + background: #e8f0fe; + color: $primary; + } + + &--past { + background: $gray-50; + color: $gray-500; + } + + &--unmapped { + background: #fce8e6; + color: $red; + } + + // Shipped with the installation, never confirmed by this bank. + &--seed { + background: #fef7e0; + color: $amber; + margin-left: 6px; + } +} + +// ── GL roles ── + +// One tab per return, so a long role list is configured a return at a time. The tabs wrap rather +// than scroll: a return whose tab had scrolled out of sight would look like one that does not exist. +.rrc-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 16px 0 18px; + border-bottom: 1px solid $border; + padding-bottom: 12px; +} + +.rrc-tab { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: 1px solid $border; + border-radius: 8px; + background: #fff; + font: inherit; + font-size: 13px; + font-weight: 600; + color: $gray-900; + cursor: pointer; + + &:hover { + background: $gray-50; + } + + &--active { + border-color: $primary; + background: rgb(19 83 160 / 6%); + color: $primary; + } + + &__badge { + font-size: 11px; + font-weight: 500; + padding: 1px 7px; + border-radius: 10px; + background: rgb(176 96 0 / 12%); + color: $amber; + + // A fully mapped return is worth stating as plainly as an incomplete one, so the tab row can + // be read at a glance instead of by elimination. + &--done { + background: rgb(19 115 51 / 12%); + color: $green; + } + } +} + +.rrc-role { + border: 1px solid $border; + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 12px; + + // An unmapped role stops a return computing, so it reads as a problem, not a blank. + &--unmapped { + border-color: #f5c6c4; + background: #fffafa; + } + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 4px; + } + + &__name { + font-size: 13.5px; + font-weight: 600; + color: $gray-900; + margin-right: 8px; + } + + &__usedby { + margin-left: 8px; + font-size: 11px; + color: $primary; + font-weight: 600; + } + + &__desc { + margin: 0 0 12px; + font-size: 12px; + color: $gray-500; + line-height: 1.6; + } + + &__controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + &__select { + flex: 1 1 420px; + } + + &__chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; + } +} + +// Spans the add-head form's columns rather than sitting in one of them. +.rrc-add-role__note { + flex-basis: 100%; + 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; + color: $gray-500 !important; + + &:hover { + color: $red !important; + } +} + +.rrc-chip { + font-size: 11px; + background: #e8f0fe; + color: $primary; + border-radius: 20px; + padding: 3px 10px; +} + +@media (width <= 720px) { + .rrc-rate-form__type, + .rrc-rate-form__pct, + .rrc-rate-form__date, + .rrc-role__select { + flex: 1 1 100%; + } +} + +/* ── mapping rows ── + A mapping is an account, a portion of its balance, and the head that portion falls under, so it + is laid out as a table rather than as chips: the three parts of one row have to read together. */ +.rrc-map { + width: 100%; + border-collapse: collapse; + margin-top: 0.75rem; + + th { + text-align: left; + font-size: 0.72rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgb(0 0 0 / 55%); + font-weight: 600; + padding: 0 0.5rem 0.25rem 0; + } + + td { + padding: 0.15rem 0.5rem 0.15rem 0; + vertical-align: middle; + } + + /* The portion and head columns hold short values; letting them take an equal share of the row + would squeeze the account name, which is the part that identifies the row. */ + &__narrow { + width: 8.5rem; + } + + &__actions { + width: 3rem; + text-align: right; + } + + &__field { + width: 100%; + } + + /* An incomplete row is marked where it is, rather than only by the disabled Save button, so it is + clear which row is holding the save back. */ + &__row--invalid td { + background: rgb(214 106 0 / 6%); + } +} + +.rrc-alert--inline { + margin: 0.5rem 0 0; + font-size: 0.82rem; +} + +/* ── adding a head ── */ +.rrc-add { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px dashed rgb(0 0 0 / 12%); + + &__form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + } + + &__desc { + flex: 1 1 16rem; + } +} diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts new file mode 100644 index 000000000..4edfb90f0 --- /dev/null +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts @@ -0,0 +1,572 @@ +/** + * 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 { catchError, forkJoin, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; + +/** + * One GL account pinned to a role: which account, how much of its balance the role takes, and - on a + * liability head - whether that portion is a demand or a time liability. + */ +export interface ReserveMapping { + glAccountId: number; + weightPercentage: number; + liabilityClass: string | null; +} + +/** + * 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 + * configured and reports nothing — so the screen states, per role, whether it is mapped at all. + * + * The accounts cannot be shipped as defaults: this platform seeds two mutually exclusive charts of + * accounts and real tenants match neither exactly, so cash on hand is 100101 on one chart and + * 101001 on another. Picking one here would read as zero on the charts it does not match. + */ +@Component({ + selector: 'mifosx-rbi-reserve-config', + templateUrl: './rbi-reserve-config.component.html', + styleUrls: ['./rbi-reserve-config.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +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, + * '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, + * and a single list makes each one harder to find than it is to make. + */ + protected readonly group: string | null = 'RESERVE'; + + /** Whether the CRR/SLR percentages belong on this screen. Only the reserve returns have rates. */ + protected readonly showRates: boolean = true; + + glRoles: any[] = []; + + /** 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[] = []; + glAccountOptions: any[] = []; + + loading = true; + savingKey: string | null = null; + savingRate = false; + errorMsg = ''; + successMsg = ''; + + /** + * Draft of the per-role mapping, keyed by role. Committed on Save. + * + * A mapping is an account plus the portion of it the role takes and, on a liability head, which + * head that portion falls under. It is not a plain list of account ids because one account can + * appear twice under one role: RBI treats part of a savings balance as a demand liability and the + * rest as time, which is two rows against the same account. + */ + selection: Record = {}; + + /** The heads a liability can fall under, and the label each carries on screen. */ + readonly liabilityClasses = [ + { value: 'DEMAND', label: 'Demand' }, + { value: 'TIME', label: 'Time' } + ]; + + /** + * How a head an administrator adds is consumed by the return. + * + * 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)' }, + { 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' } + ]; + + /** New-head form. */ + newRole: { label: string; description: string; usage: string } = { label: '', description: '', usage: 'LIABILITY' }; + addingRole = false; + showAddRole = false; + + /** New-rate form. Defaults to today so the common case is one edit and Save. */ + newRate: { rateType: string; percentage: number | null; effectiveFrom: string } = { + rateType: 'CRR', + percentage: null, + effectiveFrom: new Date().toISOString().slice(0, 10) + }; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + forkJoin({ + config: this.organizationService.getRbiReserveConfig().pipe(catchError(() => of(null))), + template: this.organizationService.getRbiReserveConfigTemplate().pipe(catchError(() => of(null))) + }).subscribe(({ config, template }: any) => { + this.loading = false; + if (!config) { + this.errorMsg = 'Could not load RBI reserve configuration.'; + return; + } + this.applyConfig(config); + this.glAccountOptions = template?.glAccountOptions || []; + }); + } + + private applyConfig(config: any): void { + // 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.rolesForScreen(); + this.rates = config.rates || []; + this.selection = {}; + for (const role of this.glRoles) { + // Read back what is stored rather than re-derived: a weight or a class the administrator set + // deliberately would otherwise be replaced by a default on the next save. + this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({ + glAccountId: a.glAccountId, + weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage, + liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null) + })); + } + this.rebuildGroups(); + } + + /** + * The roles split by the return they feed, so one screen can configure four returns without + * presenting thirty-four roles as a single list where the four that drive CRR are lost among + * the thirty that do not. + * + * Built from the group the backend states rather than from a key prefix: a role's grouping is + * part of what the return means, and duplicating that rule here would let the two drift. + */ + private rebuildGroups(): void { + const byGroup = new Map(); + for (const role of this.glRoles) { + const group = role.group || 'OTHER'; + if (!byGroup.has(group)) { + byGroup.set(group, { label: role.groupLabel || group, roles: [] }); + } + byGroup.get(group)!.roles.push(role); + } + this.roleGroups = [...byGroup.entries()].map( + ([ + name, + entry + ]) => ({ name, label: entry.label, roles: entry.roles }) + ); + + // Keep the open tab if it still exists, so saving a role does not throw the user back to the + // first tab with their place lost. + if (!this.roleGroups.some((g) => g.name === this.activeGroup)) { + this.activeGroup = this.roleGroups[0]?.name ?? ''; + } + } + + /** Tabs only earn their place when the screen shows more than one return's roles. */ + get showTabs(): boolean { + return this.roleGroups.length > 1; + } + + /** Heading and blurb, overridden per return by the screens scoped to one. */ + get screenTitle(): string { + return 'RBI Reserve Configuration'; + } + + get screenSubtitle(): string { + return ( + '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.' + ); + } + + /** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */ + isUnmapped(role: any): boolean { + return !(this.selection[role.configKey] || []).length; + } + + get unmappedCount(): number { + return this.glRoles.filter((r) => this.isUnmapped(r)).length; + } + + /** Unmapped roles within one group, so each tab carries its own count. */ + unmappedIn(group: { roles: any[] }): number { + return group.roles.filter((r) => this.isUnmapped(r)).length; + } + + showGroup(name: string): void { + this.activeGroup = name; + } + + get visibleRoles(): any[] { + return this.roleGroups.find((g) => g.name === this.activeGroup)?.roles ?? []; + } + + /** True once the draft differs from what is stored, so Save is only offered when useful. */ + isDirty(role: any): boolean { + const key = (m: any) => `${m.glAccountId}/${m.liabilityClass ?? ''}/${+m.weightPercentage}`; + const stored = (role.accounts || []) + .map((a: any) => key({ ...a, weightPercentage: a.weightPercentage ?? 100 })) + .sort(); + const draft = (this.selection[role.configKey] || []).map(key).sort(); + return stored.length !== draft.length || stored.some((k: string, i: number) => k !== draft[i]); + } + + /** Adds an empty row for this role, for the administrator to pick an account into. */ + addMapping(role: any): void { + // Defaults to the whole balance, and to Time on a liability head. Understating demand is the + // conservative default: demand attracts the heavier treatment, so a forgotten class cannot make + // a bank look compliant when it is not. + this.selection[role.configKey] = [ + ...(this.selection[role.configKey] || []), + { glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }]; + } + + removeMapping(role: any, index: number): void { + this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index); + } + + /** + * Accounts still selectable in a given row: everything not already used by ANOTHER row under the + * same head. The same account under a different head is left available, because a demand slice and + * a time slice of one savings account are two different figures, not a duplicate. + */ + availableAccounts(role: any, index: number): any[] { + const rows = this.selection[role.configKey] || []; + const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`)); + const current = rows[index]; + return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`)); + } + + /** A row that cannot be saved - no account chosen, or a portion outside 0-100. */ + isRowInvalid(mapping: ReserveMapping): boolean { + const weight = +mapping.weightPercentage; + return !mapping.glAccountId || isNaN(weight) || weight < 0 || weight > 100; + } + + /** Whether any row of this role is incomplete, so Save is withheld rather than failing server-side. */ + hasInvalidRows(role: any): boolean { + return (this.selection[role.configKey] || []).some((m) => this.isRowInvalid(m)); + } + + /** + * The demand and time subtotals this role's rows declare, as a share of the balances behind them. + * + * Shown because a split is easy to get wrong in a way no single row looks wrong in: two rows + * against one account that together take 90% of it silently drop a tenth of the balance out of the + * base, and only the total reveals it. + */ + weightTotalFor(role: any, glAccountId: number): number { + return (this.selection[role.configKey] || []) + .filter((m) => m.glAccountId === glAccountId) + .reduce((sum, m) => sum + (+m.weightPercentage || 0), 0); + } + + /** Accounts split across more than one row whose portions do not add up to the whole balance. */ + unbalancedAccounts(role: any): { glAccountId: number; total: number }[] { + const rows = this.selection[role.configKey] || []; + const ids = [...new Set(rows.map((m) => m.glAccountId).filter((id) => !!id))]; + return ids + .map((id) => ({ glAccountId: id, total: this.weightTotalFor(role, id) })) + .filter((e) => Math.abs(e.total - 100) > 0.0001); + } + + saveRole(role: any): void { + this.savingKey = role.configKey; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .updateRbiReserveGlRole( + role.configKey, + (this.selection[role.configKey] || []).map((m) => ({ + glAccountId: m.glAccountId, + weightPercentage: +m.weightPercentage, + // Sent as null off a liability head, so a class left over from a head that was retyped + // cannot travel with a figure the split does not apply to. + liabilityClass: role.classified ? m.liabilityClass : null + })) + ) + .pipe( + catchError((err: any) => { + this.savingKey = null; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.savingKey = null; + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = `${this.roleLabel(role)} updated.`; + }); + } + + saveRate(): void { + if (this.newRate.percentage === null || !this.newRate.effectiveFrom) { + this.errorMsg = 'Enter a percentage and an effective date.'; + return; + } + this.savingRate = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .upsertRbiReserveRate(this.newRate.rateType, this.newRate.percentage, this.newRate.effectiveFrom) + .pipe( + catchError((err: any) => { + this.savingRate = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this rate.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.savingRate = false; + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = `${this.newRate.rateType} rate saved.`; + this.newRate.percentage = null; + }); + } + + deleteRate(rate: any): void { + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .deleteRbiReserveRate(rate.id) + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this rate.'; + return of(null); + }) + ) + .subscribe((res: any) => { + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = 'Rate removed.'; + }); + } + + /** The rate a return filed today would use — the latest one already in effect. */ + currentRate(rateType: string): any | null { + const today = new Date().toISOString().slice(0, 10); + const inForce = this.rates.filter((r) => r.rateType === rateType && r.effectiveFrom <= today); + return inForce.length ? inForce[0] : null; + } + + ratesFor(rateType: string): any[] { + return this.rates.filter((r) => r.rateType === rateType); + } + + /** A future-dated rate is on record but not yet applied — worth saying so explicitly. */ + isFuture(rate: any): boolean { + return rate.effectiveFrom > new Date().toISOString().slice(0, 10); + } + + /** + * A role's display name and how the return uses it, both as the backend states them. + * + * Taken from the response rather than from a table here: a role only exists because code + * computes something from it, and that code is what knows what it means. A copy in the screen + * would leave a newly added role showing as a raw key. + */ + roleLabel(role: any): string { + return role?.label || role?.configKey || ''; + } + + /** Which return each role feeds, so the consequence of leaving one unmapped is visible. */ + roleUsedBy(role: any): string { + return role?.usedBy || ''; + } + + glLabel(accountId: number): string { + const account = this.glAccountOptions.find((a) => a.id === accountId); + return account ? `${account.glCode} — ${account.name}` : `#${accountId}`; + } + + // ── heads an administrator adds ── + + /** + * Adds a head to this return. The bank's own chart decides what these are - a reserve, a tax + * payable, bills payable - so they are rows rather than a fixed list in code. + */ + addRole(): void { + if (!this.newRole.label.trim()) { + this.errorMsg = 'Give the head a name.'; + return; + } + this.addingRole = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .createRbiReserveRole({ + label: this.newRole.label.trim(), + description: this.newRole.description.trim(), + // Added to the return this screen configures, so a head cannot land on a form the + // administrator is not looking at. + group: this.group ?? this.activeGroup, + usage: this.newRole.usage + }) + .pipe( + catchError((err: any) => { + this.addingRole = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not add this head.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.addingRole = false; + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = `${this.newRole.label.trim()} added. Map its GL accounts below.`; + this.newRole = { label: '', description: '', usage: 'LIABILITY' }; + this.showAddRole = false; + }); + } + + /** + * Removes a head and everything mapped to it. Confirmed first: the figures it contributed leave + * the return with it, so a return filed after this will not agree with one filed before. + */ + deleteRole(role: any): void { + if (!confirm(`Remove "${this.roleLabel(role)}" and its mapped accounts? Returns will stop counting it.`)) { + return; + } + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .deleteRbiReserveRole(role.roleId) + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this head.'; + return of(null); + }) + ) + .subscribe((res: any) => { + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = 'Head removed.'; + }); + } + + /** How a head is consumed, in words, for the screen to state next to it. */ + usageLabel(usage: string): string { + return this.roleUsages.find((u) => u.value === usage)?.label ?? usage; + } +} diff --git a/src/app/organization/tds-config/tds-config.component.html b/src/app/organization/tds-config/tds-config.component.html new file mode 100644 index 000000000..dbf1c6bfe --- /dev/null +++ b/src/app/organization/tds-config/tds-config.component.html @@ -0,0 +1,211 @@ + + +
+
+ +

TDS Configuration

+
+

+ Thresholds, rates and statutory references for TDS on deposit interest, per financial year, and the GL account + deducted tax is credited to. The TDS reports, the Form 121 register and each customer's Form 121 tab compute from + these figures only — a year not set up here is a year they refuse to compute. +

+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (loading) { +
Loading…
+ } @else { + +
+

GL account

+

+ Only postable liability accounts are listed. Nothing is preselected: the right account differs per chart of + accounts. +

+ + @for (role of glRoles; track role.configKey) { +
+
+
+ {{ role.label }} + {{ role.configKey }} +
+ @if (role.glAccountId) { + {{ role.glCode }} · {{ role.glAccountName }} + } @else { + Not mapped + } +
+

{{ role.description }}

+
+ + GL account + + — Not mapped — + @for (o of glAccountOptions; track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + + +
+
+ } +
+ + +
+

Financial years

+

+ 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. +

+ +
+ @for (y of years; track y.fy) { + + } + @if (isNew) { + + } @else { + + } +
+ + @if (draft) { + @if (draft.seedDefault && !isNew) { +
+ These figures were carried over from the values previously built into the application. Check them against + the Finance Act for FY {{ draft.fy }} and save to confirm. +
+ } + + @if (isNew) { +
+ + Financial year + + @if (!fyValid) { + Format: 2027-28 + } @else if (fyTaken) { + FY {{ draft.fy }} is already configured + } + +
+ } + +

Thresholds & rates

+
+ @for (f of rateFields; track f.key) { + + {{ f.label }} + + @if (f.hint) { + {{ f.hint }} + } + + } +
+ @if (noPanBelowRate) { +
The rate without PAN cannot be lower than the rate with PAN.
+ } + +

Declaration (Form 121 / 15G / 15H)

+
+ @for (f of declarationFields; track f.key) { + + {{ f.label }} + @if (f.kind === 'select') { + + @for (o of f.options; track o.value) { + {{ o.label }} + } + + } @else { + + } + @if (f.hint) { + {{ f.hint }} + } + + } +
+ +

Statutory references

+

Printed on the reports, certificates and returns for this year.

+
+ @for (f of statuteFields; track f.key) { + + {{ f.label }} + @if (f.kind === 'select') { + + @for (o of f.options; track o.value) { + {{ o.label }} + } + + } @else { + + } + + } +
+ +
+ + @if (isNew) { + + } @else if (years.length > 1) { + + } + @if (draft.updatedOn && !isNew) { + Last saved {{ draft.updatedOn | slice: 0 : 16 }} + } +
+ } @else { +
+ No financial year is configured. Every TDS screen is unable to compute until one is added. +
+ } +
+ } +
diff --git a/src/app/organization/tds-config/tds-config.component.scss b/src/app/organization/tds-config/tds-config.component.scss new file mode 100644 index 000000000..a6c0adc41 --- /dev/null +++ b/src/app/organization/tds-config/tds-config.component.scss @@ -0,0 +1,34 @@ +/** + * 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/. + */ + +/* Layout on top of the RBI reserve configuration styles this screen shares. */ + +.tdc-section { + margin: 20px 0 10px; + font-size: 14px; + font-weight: 600; +} + +.tdc-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 14px 16px; + margin-bottom: 8px; +} + +.tdc-gl { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + + &__field { + flex: 1 1 320px; + max-width: 520px; + } +} diff --git a/src/app/organization/tds-config/tds-config.component.ts b/src/app/organization/tds-config/tds-config.component.ts new file mode 100644 index 000000000..77fd5e928 --- /dev/null +++ b/src/app/organization/tds-config/tds-config.component.ts @@ -0,0 +1,288 @@ +/** + * 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 { catchError, forkJoin, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { + TdsConfigResponse, + TdsConfigService, + TdsFyConfigRecord, + TdsGlRole +} from 'app/letter-mgmt/certificates/form-15gh/tds-config.service'; + +/** One editable field of a financial year, as the form lays it out. */ +interface FieldDef { + key: keyof TdsFyConfigRecord; + label: string; + kind: 'amount' | 'percent' | 'int' | 'text' | 'select'; + hint?: string; + options?: { value: string; label: string }[]; +} + +/** + * TDS on deposit interest: the thresholds, rates and statutory references for each financial year, + * and the GL account deducted tax is credited to. + * + * The TDS reports, the Form 121 register and the customer's Form 121 tab all compute from what is + * saved here - nothing about the tax is compiled into the screens. A year missing here is a year + * those screens refuse to compute. + */ +@Component({ + selector: 'mifosx-tds-config', + templateUrl: './tds-config.component.html', + styleUrls: [ + '../rbi-reserve-config/rbi-reserve-config.component.scss', + './tds-config.component.scss' + ], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class TdsConfigComponent implements OnInit { + private tdsConfig = inject(TdsConfigService); + + loading = true; + saving = false; + savingGl = false; + errorMsg = ''; + successMsg = ''; + + years: TdsFyConfigRecord[] = []; + glRoles: TdsGlRole[] = []; + glAccountOptions: { id: number; name: string; glCode: string }[] = []; + + /** Year on screen, as a working copy - committed on Save. */ + draft: TdsFyConfigRecord | null = null; + /** True while the draft is a year not yet saved. */ + isNew = false; + newFy = ''; + + /** Draft GL selection per role. */ + glSelection: Record = {}; + + 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: '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' }, + { key: 'lateDepositPercentage', label: 'Late deposit interest (% per month)', kind: 'percent' }, + { key: 'roundTo', label: 'Round TDS to nearest (₹)', kind: 'int', hint: '1 = nearest rupee' } + ]; + + readonly declarationFields: FieldDef[] = [ + { + key: 'declarationForm', + label: 'Declaration form', + kind: 'select', + options: [ + { value: '121', label: 'Form 121' }, + { value: '15G', label: 'Form 15G / 15H' } + ] + }, + { key: 'basicExemption', label: 'Basic exemption limit (₹)', kind: 'amount' }, + { key: 'nilTaxCeiling', label: 'Nil-tax income ceiling (₹)', kind: 'amount' }, + { + key: 'declarationStatementFrequency', + label: 'Declaration statement', + kind: 'select', + options: [ + { value: 'MONTHLY', label: 'Monthly' }, + { value: 'QUARTERLY', label: 'Quarterly' } + ] + }, + { + key: 'declarationStatementDueDay', + label: 'Statement due on day', + kind: 'int', + hint: 'Of the following month / quarter' + } + ]; + + readonly statuteFields: FieldDef[] = [ + { + key: 'regime', + label: 'Governing Act', + kind: 'select', + options: [ + { value: 'ITA_2025', label: 'Income Tax Act, 2025' }, + { value: 'ITA_1961', label: 'Income-tax Act, 1961' } + ] + }, + { key: 'interestTdsSection', label: 'Section charging TDS on interest', kind: 'text' }, + { key: 'thresholdSection', label: 'Threshold section', kind: 'text' }, + { key: 'noPanSection', label: 'No-PAN higher rate section', kind: 'text' }, + { key: 'defaultSection', label: 'Default / late interest section', kind: 'text' }, + { key: 'declarationSection', label: 'Declaration section', kind: 'text' }, + { key: 'declarationRule', label: 'Declaration rule', kind: 'text' }, + { key: 'tdsStatementForm', label: 'Quarterly TDS return', kind: 'text' }, + { key: 'tdsStatementRule', label: 'TDS return rule', kind: 'text' }, + { key: 'certificateForm', label: 'TDS certificate', kind: 'text' } + ]; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + forkJoin({ + config: this.tdsConfig.load().pipe(catchError(() => of(null))), + template: this.tdsConfig.template().pipe(catchError(() => of(null))) + }).subscribe(({ config, template }) => { + this.loading = false; + if (!config) { + this.errorMsg = 'Could not load TDS configuration.'; + return; + } + this.glAccountOptions = template?.glAccountOptions || []; + this.apply(config, this.draft?.fy); + }); + } + + private apply(config: TdsConfigResponse, keepFy?: string): void { + this.years = config.years || []; + this.glRoles = config.glRoles || []; + this.glSelection = {}; + for (const role of this.glRoles) { + this.glSelection[role.configKey] = role.glAccountId; + } + const target = this.years.find((y) => y.fy === keepFy) ?? this.years[0] ?? null; + this.select(target); + } + + select(year: TdsFyConfigRecord | null): void { + this.draft = year ? { ...year } : null; + this.isNew = false; + } + + get unmappedGl(): TdsGlRole[] { + return this.glRoles.filter((r) => !r.glAccountId); + } + + /** The year after the latest configured one, e.g. '2027-28' after '2026-27'. */ + private nextFy(): string { + const latest = this.years[0]?.fy; + const start = latest ? Number(latest.slice(0, 4)) + 1 : new Date().getFullYear(); + return `${start}-${String(start + 1).slice(2)}`; + } + + /** + * Starts a new year from the latest one's figures. The law rarely changes all of them at once, so + * copying is the common case; nothing is saved until the administrator reviews and saves it. + */ + startNewYear(): void { + const base = this.years[0]; + this.newFy = this.nextFy(); + this.draft = base + ? { ...base, id: undefined, fy: this.newFy, seedDefault: false, updatedOn: null } + : ({ + fy: this.newFy, + regime: 'ITA_2025', + declarationForm: '121', + declarationStatementFrequency: 'MONTHLY', + roundTo: 1 + } as TdsFyConfigRecord); + this.isNew = true; + this.successMsg = ''; + } + + cancelNew(): void { + this.select(this.years[0] ?? null); + } + + get fyValid(): boolean { + const m = /^(\d{4})-(\d{2})$/.exec(this.draft?.fy ?? ''); + return !!m && (Number(m[1]) + 1) % 100 === Number(m[2]); + } + + get fyTaken(): boolean { + return this.isNew && this.years.some((y) => y.fy === this.draft?.fy); + } + + /** The no-PAN rate is a penalty rate; below the ordinary rate it would reward a missing PAN. */ + get noPanBelowRate(): boolean { + return !!this.draft && Number(this.draft.rateNoPanPercentage) < Number(this.draft.ratePanPercentage); + } + + get canSave(): boolean { + return !!this.draft && this.fyValid && !this.fyTaken && !this.noPanBelowRate && !this.saving; + } + + save(): void { + if (!this.draft || !this.canSave) { + return; + } + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + const fy = this.draft.fy; + this.tdsConfig.saveYear(this.draft).subscribe({ + next: (res) => { + this.saving = false; + this.apply(res, fy); + this.successMsg = `FY ${fy} saved. Every TDS screen now computes with these figures.`; + }, + error: (err) => { + this.saving = false; + this.errorMsg = this.messageOf(err, `Could not save FY ${fy}.`); + } + }); + } + + deleteYear(): void { + if (!this.draft || this.isNew) { + return; + } + const fy = this.draft.fy; + if (!confirm(`Remove the TDS configuration for FY ${fy}? TDS screens will stop computing that year.`)) { + return; + } + this.tdsConfig.deleteYear(fy).subscribe({ + next: (res) => { + this.apply(res); + this.successMsg = `FY ${fy} removed.`; + }, + error: (err) => (this.errorMsg = this.messageOf(err, `Could not remove FY ${fy}.`)) + }); + } + + saveGl(role: TdsGlRole): void { + this.savingGl = true; + this.errorMsg = ''; + this.successMsg = ''; + this.tdsConfig.saveGl(role.configKey, this.glSelection[role.configKey] ?? null).subscribe({ + next: (res) => { + this.savingGl = false; + this.apply(res, this.draft?.fy); + this.successMsg = `${role.label} GL saved.`; + }, + error: (err) => { + this.savingGl = false; + this.errorMsg = this.messageOf(err, `Could not save ${role.label} GL.`); + } + }); + } + + isGlDirty(role: TdsGlRole): boolean { + return (this.glSelection[role.configKey] ?? null) !== (role.glAccountId ?? null); + } + + private messageOf(err: any, fallback: string): string { + return err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || fallback; + } +} diff --git a/src/app/organization/working-days/working-days.component.ts b/src/app/organization/working-days/working-days.component.ts index b175dafc1..dc88ae7ab 100644 --- a/src/app/organization/working-days/working-days.component.ts +++ b/src/app/organization/working-days/working-days.component.ts @@ -23,6 +23,7 @@ import { OrganizationService } from '../organization.service'; import { SettingsService } from 'app/settings/settings.service'; import { PopoverService } from '../../configuration-wizard/popover/popover.service'; import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service'; +import { AlertService } from 'app/core/alert/alert.service'; /** Custom Dialog Components */ import { NextStepDialogComponent } from '../../configuration-wizard/next-step-dialog/next-step-dialog.component'; @@ -53,6 +54,7 @@ export class WorkingDaysComponent implements OnInit, AfterViewInit { private dialog = inject(MatDialog); private configurationWizardService = inject(ConfigurationWizardService); private popoverService = inject(PopoverService); + private alertService = inject(AlertService); /** Working days form. */ workingDaysForm: UntypedFormGroup; @@ -152,7 +154,13 @@ export class WorkingDaysComponent implements OnInit, AfterViewInit { } } workingDays.recurrence = recurrence; - this.organizationService.updateWorkingDays(workingDays).subscribe((response) => { + this.organizationService.updateWorkingDays(workingDays).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Working Days update has been submitted for approval.' + }); + } if (this.configurationWizardService.showDefineWorkingDays) { this.configurationWizardService.showDefineWorkingDays = false; this.openNextStepDialog(); diff --git a/src/app/products/current-products/create-current-product/create-current-product.component.html b/src/app/products/current-products/create-current-product/create-current-product.component.html index 6662df7d6..a1ca46345 100644 --- a/src/app/products/current-products/create-current-product/create-current-product.component.html +++ b/src/app/products/current-products/create-current-product/create-current-product.component.html @@ -50,6 +50,7 @@
diff --git a/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts index 252629bc5..7f1b8a8e0 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts @@ -127,15 +127,11 @@ export class CurrentProductAccountingStepComponent implements OnInit { private patchAccountingValues(mappings: any) { this.CurrentProductAccountingForm.patchValue({ - savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '', savingsControlAccountId: mappings.savingsControlAccount?.id || '', - transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '', - interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '', incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '', incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '', incomeFromInterestId: mappings.incomeFromInterest?.id || '', overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '', - writeOffAccountId: mappings.writeOffAccount?.id || '', feesReceivableAccountId: mappings.feeReceivableAccount?.id || '', penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '', interestPayableAccountId: mappings.interestPayableAccount?.id || '', @@ -267,14 +263,16 @@ export class CurrentProductAccountingStepComponent implements OnInit { }; if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) { - addControl('savingsReferenceAccountId', true); + // A current account pays no interest, has no write-off and does not use a transfers suspense account, so + // Interest On Savings, Write Off and Transfers In Suspense are not mapped. The keys stay savings-named + // because the backend's GL mapping reads them under those names. + // No Current Reference either: a current account's cash side posts to the branch cash GL (Financial + // Activity: Cash at Main Vault / Teller), the same one the cashier screen uses. + removeControl('savingsReferenceAccountId'); addControl('savingsControlAccountId', true); - addControl('transfersInSuspenseAccountId', true); - addControl('interestOnSavingsAccountId', true); addControl('incomeFromFeeAccountId', true); addControl('incomeFromPenaltyAccountId', true); addControl('incomeFromInterestId', true); - addControl('writeOffAccountId', true); addControl('overdraftPortfolioControlId', false); if (this.accounting.isAccrualAccountingRuleId(accountingRule)) { @@ -297,12 +295,9 @@ export class CurrentProductAccountingStepComponent implements OnInit { } else { removeControl('savingsReferenceAccountId'); removeControl('savingsControlAccountId'); - removeControl('transfersInSuspenseAccountId'); - removeControl('interestOnSavingsAccountId'); removeControl('incomeFromFeeAccountId'); removeControl('incomeFromPenaltyAccountId'); removeControl('incomeFromInterestId'); - removeControl('writeOffAccountId'); removeControl('overdraftPortfolioControlId'); removeControl('feesReceivableAccountId'); removeControl('penaltiesReceivableAccountId'); @@ -345,14 +340,6 @@ export class CurrentProductAccountingStepComponent implements OnInit { pillLabel: 'Asset', pillClass: 'green', rows: [ - { - key: 'savingsReferenceAccountId', - label: 'Savings Reference Account', - inputLabel: 'Savings reference account', - controlName: 'savingsReferenceAccountId', - options: this.assetAccountOptions, - required: true - }, { key: 'overdraftPortfolioControlId', label: 'Overdraft Portfolio Control', @@ -396,20 +383,12 @@ export class CurrentProductAccountingStepComponent implements OnInit { rows: [ { key: 'savingsControlAccountId', - label: 'Savings Control Account', - inputLabel: 'Savings control account', + label: 'Current Control Account', + inputLabel: 'Current control account', controlName: 'savingsControlAccountId', options: this.liabilityAccountOptions, required: true }, - { - key: 'transfersInSuspenseAccountId', - label: 'Transfers In Suspense Account', - inputLabel: 'Transfers in suspense account', - controlName: 'transfersInSuspenseAccountId', - options: this.liabilityAccountOptions, - required: true - }, { key: 'interestPayableAccountId', label: 'Interest Payable Account', @@ -430,32 +409,8 @@ export class CurrentProductAccountingStepComponent implements OnInit { otherMappingsControlName: 'liabilitiesOtherGlMappings', otherAccountOptions: this.liabilityAccountOptions }, - { - key: 'expenses', - title: 'Expenses', - pillLabel: 'Expense', - pillClass: 'amber', - rows: [ - { - key: 'interestOnSavingsAccountId', - label: 'Interest On Savings Account', - inputLabel: 'Interest on savings account', - controlName: 'interestOnSavingsAccountId', - options: this.expenseAccountOptions, - required: true - }, - { - key: 'writeOffAccountId', - label: 'Write Off Account', - inputLabel: 'Write off account', - controlName: 'writeOffAccountId', - options: this.expenseAccountOptions, - required: true - } - ], - otherMappingsControlName: 'expensesOtherGlMappings', - otherAccountOptions: this.expenseAccountOptions - }, + // No Expenses section: a current account pays no interest and has no write-off, so it maps no expense GL. + // The expensesOtherGlMappings control is kept, so an older product's saved expense mappings survive an edit. { key: 'income', title: 'Income', diff --git a/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html index c74008227..f01407d85 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html @@ -162,29 +162,16 @@ }}
+
+ Days in Year: + {{ CurrentProduct.interestCalculationDaysInYearType }} Days +
+
Auto Interest Post: {{ CurrentProduct.autoPost | yesNo }}
- @if ( - CurrentProduct.frequencyFixDay !== undefined && - CurrentProduct.frequencyFixDay !== null && - CurrentProduct.frequencyFixDay !== '' - ) { -
- Frequency (Fix Day): - {{ CurrentProduct.frequencyFixDay }} Days -
- } - - @if (CurrentProduct.lastInterestDate) { -
- Last Interest Date: - {{ CurrentProduct.lastInterestDate | dateFormat }} -
- } -

{{ 'labels.inputs.Settings' | translate }}

@@ -282,39 +269,58 @@
@if (CurrentProduct.allowOverdraft) { - @if (CurrentProduct.avgBalanceCalcPeriod) { -
- Avg Balance Calculation Period: - {{ CurrentProduct.avgBalanceCalcPeriod }} -
- } +
+ Maximum OD Limit: + ₹ {{ CurrentProduct.overdraftLimit | formatNumber }} +
- @if (CurrentProduct.validForPeriod) { + @if (CurrentProduct.odMinLimit) {
- Valid for Period: - {{ CurrentProduct.validForPeriod }} -
- } - - @if (CurrentProduct.overdraftLimit !== undefined && CurrentProduct.overdraftLimit !== '') { -
- OD Limit %: - {{ CurrentProduct.overdraftLimit }} -
- } - - @if ( - CurrentProduct.nominalAnnualInterestRateOverdraft !== undefined && - CurrentProduct.nominalAnnualInterestRateOverdraft !== '' - ) { -
- Interest on OD %: - {{ CurrentProduct.nominalAnnualInterestRateOverdraft }} + Minimum OD Limit: + ₹ {{ CurrentProduct.odMinLimit | formatNumber }}
}
- OD Account Closed with Zero Balance in Day Closing: + OD Security Type: + {{ CurrentProduct.odSecurityType | titlecase }} +
+ +
+ Sanction Validity: + {{ CurrentProduct.validForPeriod }} +
+ +
+ Excess Drawing Allowed: + {{ CurrentProduct.odExcessAllowed | yesNo }} +
+ + @if (CurrentProduct.odExcessAllowed) { +
+ Maximum Excess: + {{ CurrentProduct.odExcessLimitPercent }} % of limit +
+
+ Penal Charge on Excess: + {{ CurrentProduct.odPenalRate }} % p.a. +
+ } + +
+ OD Interest Rate: + {{ CurrentProduct.nominalAnnualInterestRateOverdraft }} % p.a. +
+ + @if (CurrentProduct.minOverdraftForInterestCalculation) { +
+ Minimum OD for Interest: + ₹ {{ CurrentProduct.minOverdraftForInterestCalculation | formatNumber }} +
+ } + +
+ End OD Once Fully Repaid: {{ CurrentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}
} @@ -453,15 +459,6 @@
Assets
-
- Savings reference account - {{ - formatGlAccountById( - CurrentProduct.savingsReferenceAccountId, - CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
Overdraft portfolio control {{ @@ -503,7 +500,7 @@
Liabilities
- Savings control account + Current control account {{ formatGlAccountById( CurrentProduct.savingsControlAccountId, @@ -511,15 +508,6 @@ ) }}
-
- Transfers in suspense account - {{ - formatGlAccountById( - CurrentProduct.transfersInSuspenseAccountId, - CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions - ) - }} -
Interest payable account {{ @@ -542,28 +530,6 @@ }
-
-
Expenses
-
- Interest on savings account - {{ - formatGlAccountById( - CurrentProduct.interestOnSavingsAccountId, - CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions - ) - }} -
-
- Write off account - {{ - formatGlAccountById( - CurrentProduct.writeOffAccountId, - CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions - ) - }} -
-
-
Income
diff --git a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html index 11467b21d..1bc750acb 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html @@ -110,72 +110,126 @@ Overdraft (OD) Facility
+ + @if (!CurrentProductSettingsForm.value.allowOverdraft) { +

+ Overdraft is not allowed on this product. Tick "Is Overdraft Allowed" on the Terms tab to set the OD limits. +

+ }
-
- - Is Overdraft Allowed - -
@if (CurrentProductSettingsForm.value.allowOverdraft) {
- OD Account Closed with Zero Balance in Day Closing + End OD Once Fully Repaid (One-time OD only)

- Automatically closes OD accounts when balance reaches zero during day-end closing. + At day-end, withdraws the OD sanction when the debit balance is cleared to zero. Only the OD ends, not + the account. Leave off for a regular OD: a customer who uses it and repays the same day would lose it.

}
- + @if (CurrentProductSettingsForm.value.allowOverdraft) {
-
- Average Balance Calculation in Period - - 6M - 1Y - 3Y - -
- -
- Valid for Period - - 3M - 1Y - 3Y - -
-
- OD Limit % + Maximum OD Limit (₹) + Highest per account + Enter the maximum OD limit - Interest on OD % - + Minimum OD Limit (₹) + + Lowest per account + @if (CurrentProductSettingsForm.get('odMinLimit')?.hasError('exceedsMax')) { + Cannot exceed the maximum OD limit + } @else { + Cannot be negative + }
+ +
+ OD Security Type + + @for (option of odSecurityTypeOptions; track option.value) { + {{ option.label }} + } + +

{{ selectedSecurityTypeHint }}

+
+ +
+ Sanction Validity + + @for (option of odValidityOptions; track option.value) { + {{ option.label }} + } + +
+ +
+ Excess Drawing (Over Limit) + + Allow withdrawal beyond the sanctioned limit + + @if (!CurrentProductSettingsForm.value.odExcessAllowed) { +

Off: a withdrawal that would cross the limit is refused.

+ } @else { +
+ + Maximum Excess (% of limit) + + Beyond this, refused + Enter above 0% and up to 25% + + + + Penal Charge on Excess (% p.a.) + + Only on the excess amount + Enter above 0% and up to 50% + +
+ @if (excessExample) { +

{{ excessExample }}

+ } + } +
+ +

+ Each account is sanctioned its own limit within this range, valid for the period above. The OD interest + rate is set on the Terms tab. +

}
diff --git a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts index de2e04d2d..f68d32c94 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts @@ -7,7 +7,7 @@ */ import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, ValidationErrors, Validators } from '@angular/forms'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -40,6 +40,21 @@ export class CurrentProductSettingsStepComponent implements OnInit { { id: 'YEARS', value: 'value.years' } ]; + /** How long an OD sanction on this product runs before it must be renewed. */ + odValidityOptions = [ + { value: '3M', label: '3 Months' }, + { value: '6M', label: '6 Months' }, + { value: '1Y', label: '1 Year' }, + { value: '2Y', label: '2 Years' }, + { value: '3Y', label: '3 Years' } + ]; + + odSecurityTypeOptions = [ + { value: 'CLEAN', label: 'Clean', hint: 'No security needed' }, + { value: 'SECURED', label: 'Secured', hint: 'Against FD lien, property, etc.' }, + { value: 'BOTH', label: 'Both', hint: 'Decided at each sanction' } + ]; + constructor() { this.createCurrentProductSettingsForm(); this.setConditionalControls(); @@ -72,14 +87,15 @@ export class CurrentProductSettingsStepComponent implements OnInit { daysToEscheat: isEditing ? this.CurrentProductsTemplate.daysToEscheat || DORMANCY_DEFAULTS.daysToEscheat : DORMANCY_DEFAULTS.daysToEscheat, - // Overdraft fields + // Overdraft scheme (the OD interest rate lives on the Terms tab) allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false, overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '', - nominalAnnualInterestRateOverdraft: isEditing - ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft - : '', - avgBalanceCalcPeriod: isEditing ? this.CurrentProductsTemplate.avgBalanceCalcPeriod || '1Y' : '1Y', - validForPeriod: isEditing ? this.CurrentProductsTemplate.validForPeriod || '1Y' : '1Y', + odMinLimit: isEditing ? this.CurrentProductsTemplate.odMinLimit : '', + odSecurityType: isEditing ? this.CurrentProductsTemplate.odSecurityType || 'CLEAN' : 'CLEAN', + validForPeriod: this.validityOrDefault(isEditing ? this.CurrentProductsTemplate.validForPeriod : null), + odExcessAllowed: isEditing ? !!this.CurrentProductsTemplate.odExcessAllowed : false, + odExcessLimitPercent: isEditing ? this.CurrentProductsTemplate.odExcessLimitPercent : '', + odPenalRate: isEditing ? this.CurrentProductsTemplate.odPenalRate : '', odAccountClosedZeroBalanceInDayClosing: isEditing ? (this.CurrentProductsTemplate.odAccountClosedZeroBalanceInDayClosing ?? false) : false @@ -123,23 +139,119 @@ export class CurrentProductSettingsStepComponent implements OnInit { daysToInactive: [DORMANCY_DEFAULTS.daysToInactive], daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy], daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat], - // Overdraft (OD) Facility + // Overdraft (OD) Facility: the scheme an account's OD sanction is held to allowOverdraft: [false], - overdraftLimit: [ - '', - [Validators.min(0)] - ], - nominalAnnualInterestRateOverdraft: [ - '', - [Validators.min(0)] - ], - avgBalanceCalcPeriod: ['1Y'], + overdraftLimit: [''], + odMinLimit: [''], + odSecurityType: ['CLEAN'], validForPeriod: ['1Y'], + // Excess drawing past the sanctioned limit: off means it is refused + odExcessAllowed: [false], + odExcessLimitPercent: [''], + odPenalRate: [''], odAccountClosedZeroBalanceInDayClosing: [false] }); } + get selectedSecurityTypeHint(): string { + const selected = this.CurrentProductSettingsForm.get('odSecurityType')?.value; + return this.odSecurityTypeOptions.find((option) => option.value === selected)?.hint ?? ''; + } + + /** A worked example on the maximum limit, so the percentages read as rupees. */ + get excessExample(): string { + const value = this.CurrentProductSettingsForm.value; + const limit = Number(value.overdraftLimit); + const percent = Number(value.odExcessLimitPercent); + const penal = Number(value.odPenalRate); + if (!limit || !percent) return ''; + const excess = (limit * percent) / 100; + const inr = (n: number) => '₹' + n.toLocaleString('en-IN', { maximumFractionDigits: 2 }); + let text = `On a ${inr(limit)} limit, up to ${inr(excess)} more may be drawn.`; + if (penal) { + text += ` If ${inr(excess)} stays over the limit for a day, the penal charge is ${inr((excess * penal) / 100 / 365)}.`; + } + return text; + } + + private validityOrDefault(value: string | null): string { + return this.odValidityOptions.some((option) => option.value === value) ? value : '1Y'; + } + + /** The minimum sanction may not exceed the maximum. */ + private odMinWithinMax = (control: AbstractControl): ValidationErrors | null => { + const max = Number(this.CurrentProductSettingsForm?.get('overdraftLimit')?.value); + const min = Number(control.value); + if (control.value === '' || control.value === null || !max) return null; + return min > max ? { exceedsMax: true } : null; + }; + + private applyOverdraftValidators(allowed: boolean) { + const max = this.CurrentProductSettingsForm.get('overdraftLimit'); + const min = this.CurrentProductSettingsForm.get('odMinLimit'); + const security = this.CurrentProductSettingsForm.get('odSecurityType'); + const validity = this.CurrentProductSettingsForm.get('validForPeriod'); + if (allowed) { + max.setValidators([ + Validators.required, + Validators.min(1) + ]); + min.setValidators([ + Validators.min(0), + this.odMinWithinMax + ]); + security.setValidators([Validators.required]); + validity.setValidators([Validators.required]); + } else { + [ + max, + min, + security, + validity + ].forEach((control) => control.clearValidators()); + } + [ + max, + min, + security, + validity + ].forEach((control) => control.updateValueAndValidity({ emitEvent: false })); + this.applyExcessValidators(); + } + + /** The excess cap and penal rate are required only while both OD and excess drawing are on. */ + private applyExcessValidators() { + const form = this.CurrentProductSettingsForm; + const required = !!form.get('allowOverdraft').value && !!form.get('odExcessAllowed').value; + const percent = form.get('odExcessLimitPercent'); + const penal = form.get('odPenalRate'); + percent.setValidators( + required ? [ + Validators.required, + Validators.min(0.01), + Validators.max(25) + ] : [] + ); + penal.setValidators( + required ? [ + Validators.required, + Validators.min(0.01), + Validators.max(50) + ] : [] + ); + percent.updateValueAndValidity({ emitEvent: false }); + penal.updateValueAndValidity({ emitEvent: false }); + } + setConditionalControls() { + this.CurrentProductSettingsForm.get('allowOverdraft').valueChanges.subscribe((allowed: boolean) => + this.applyOverdraftValidators(!!allowed) + ); + this.CurrentProductSettingsForm.get('odExcessAllowed').valueChanges.subscribe(() => this.applyExcessValidators()); + this.CurrentProductSettingsForm.get('overdraftLimit').valueChanges.subscribe(() => + this.CurrentProductSettingsForm.get('odMinLimit').updateValueAndValidity({ emitEvent: false }) + ); + this.CurrentProductSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enabled: boolean) => { const freq = this.CurrentProductSettingsForm.get('lockinPeriodFrequency'); const type = this.CurrentProductSettingsForm.get('lockinPeriodFrequencyType'); @@ -168,10 +280,15 @@ export class CurrentProductSettingsStepComponent implements OnInit { if (!formValue.allowOverdraft) { delete formValue.overdraftLimit; - delete formValue.nominalAnnualInterestRateOverdraft; - delete formValue.avgBalanceCalcPeriod; + delete formValue.odMinLimit; + delete formValue.odSecurityType; delete formValue.validForPeriod; delete formValue.odAccountClosedZeroBalanceInDayClosing; + delete formValue.odExcessAllowed; + } + if (!formValue.allowOverdraft || !formValue.odExcessAllowed) { + delete formValue.odExcessLimitPercent; + delete formValue.odPenalRate; } return formValue; diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html index f2350d95a..a7d6adee9 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html @@ -13,20 +13,78 @@ Interest On OD
- - Interest Posting Frequency * - - @for (item of interestPostingPeriodTypeData; track item) { - - {{ item.value | translateKey: 'catalogs' }} - - } - - - Interest Posting Frequency {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - + @if (allowOverdraftControl) { +
+ + Is Overdraft Allowed + +
+ } + @if (!isOverdraftAllowed) { +
+ ℹ️ + Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab. +
+ } + + - - - Frequency (Fix Day) - - Days - - - - Last Interest Date - - - -
-
- ⚠️ - Daily product-based interest accrual enabled — interest calculated EOD on daily closing balance. -
+ @if (isOverdraftAllowed) { +
+ ℹ️ + + Rule: daily OD interest = end-of-day debit balance × rate ÷ days in year, applied to the account at the + posting frequency. + +
+ }
diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts index aaab2e0aa..4f41c6e41 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts @@ -7,12 +7,21 @@ */ import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { AbstractControl, UntypedFormControl, UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { Dates } from 'app/core/utils/dates'; -import { SettingsService } from 'app/settings/settings.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +/** Posting-period ids an OD's interest may be applied on: RBI requires monthly for advances; quarterly is the outer limit. */ +const OD_POSTING_PERIOD_IDS = [ + 4, + 5 +]; + +/** + * Interest on OD: the rate and rules an overdraft on this product is charged by. Interest is worked out daily on the + * end-of-day debit balance and applied to the account at the posting frequency. The fields apply only when the + * Settings tab allows an overdraft, so allowOverdraft (that tab's control) switches their validators on and off. + */ @Component({ selector: 'mifosx-current-product-terms-step', templateUrl: './current-product-terms-step.component.html', @@ -24,14 +33,18 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; }) export class CurrentProductTermsStepComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); - private dateUtils = inject(Dates); - private settingsService = inject(SettingsService); @Input() CurrentProductsTemplate: any; + /** The Settings tab's allowOverdraft control. */ + @Input() allowOverdraft: AbstractControl; CurrentProductTermsForm: UntypedFormGroup; interestPostingPeriodTypeData: any[] = []; + daysInYearData: Array<{ id: number; label: string }> = [ + { id: 365, label: '365 Days (Actual)' }, + { id: 360, label: '360 Days' } + ]; yesNoOptions: Array<{ value: boolean; label: string }> = [ { value: true, label: 'Yes' }, { value: false, label: 'No' } @@ -41,29 +54,61 @@ export class CurrentProductTermsStepComponent implements OnInit { this.createCurrentProductTermsForm(); } + get isOverdraftAllowed(): boolean { + return !!this.allowOverdraft?.value; + } + + /** + * The Settings tab's own control, so the box can be ticked here without going to that tab first; the two + * checkboxes are one control and always agree. + */ + get allowOverdraftControl(): UntypedFormControl | null { + return this.allowOverdraft instanceof UntypedFormControl ? this.allowOverdraft : null; + } + ngOnInit() { - this.interestPostingPeriodTypeData = this.CurrentProductsTemplate.interestPostingPeriodTypeOptions || []; + const postingOptions: any[] = this.CurrentProductsTemplate.interestPostingPeriodTypeOptions || []; + const odPostingOptions = postingOptions.filter((option: any) => OD_POSTING_PERIOD_IDS.includes(option.id)); + this.interestPostingPeriodTypeData = odPostingOptions.length ? odPostingOptions : postingOptions; this.setYesNoOptions(this.CurrentProductsTemplate?.yesNoOptions); const isEditing = !!this.CurrentProductsTemplate.id; + const defaultPosting = + this.interestPostingPeriodTypeData.find((option: any) => option.id === 4)?.id ?? + this.interestPostingPeriodTypeData[0]?.id ?? + ''; + const storedPosting = this.CurrentProductsTemplate.interestPostingPeriodType?.id; + const storedDaysInYear = this.CurrentProductsTemplate.interestCalculationDaysInYearType?.id; + const storedPostingOffered = this.interestPostingPeriodTypeData.some((option: any) => option.id === storedPosting); this.CurrentProductTermsForm.patchValue({ - interestPostingPeriodType: this.CurrentProductsTemplate.interestPostingPeriodType?.id || '', - autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true, - frequencyFixDay: isEditing ? (this.CurrentProductsTemplate.frequencyFixDay ?? 30) : 30, - lastInterestDate: this.parseDate(this.CurrentProductsTemplate.lastInterestDate) + interestPostingPeriodType: storedPostingOffered ? storedPosting : defaultPosting, + interestCalculationDaysInYearType: storedDaysInYear === 360 ? 360 : 365, + nominalAnnualInterestRateOverdraft: isEditing + ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft + : '', + minOverdraftForInterestCalculation: isEditing + ? this.CurrentProductsTemplate.minOverdraftForInterestCalculation + : '', + autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true }); + + // The Settings tab patches allowOverdraft in its own ngOnInit, after this one, so follow its changes as well as + // its current value. + this.applyOverdraftValidators(this.isOverdraftAllowed); + this.allowOverdraft?.valueChanges.subscribe((allowed: boolean) => this.applyOverdraftValidators(!!allowed)); } - private parseDate(dateValue: any): Date | null { - if (!dateValue) return null; - if (Array.isArray(dateValue)) { - return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]); - } - if (dateValue instanceof Date) { - return dateValue; - } - return new Date(dateValue); + private applyOverdraftValidators(allowed: boolean) { + const rate = this.CurrentProductTermsForm.get('nominalAnnualInterestRateOverdraft'); + rate.setValidators( + allowed ? [ + Validators.required, + Validators.min(0.01), + Validators.max(50) + ] : [Validators.min(0)] + ); + rate.updateValueAndValidity({ emitEvent: false }); } private setYesNoOptions(options: any[]) { @@ -90,23 +135,25 @@ export class CurrentProductTermsStepComponent implements OnInit { '', Validators.required ], - autoPost: [true], - frequencyFixDay: [ - 30, + interestCalculationDaysInYearType: [365], + nominalAnnualInterestRateOverdraft: [ + '', [Validators.min(0)] ], - lastInterestDate: [''] + minOverdraftForInterestCalculation: [ + '', + [Validators.min(0)] + ], + autoPost: [true] }); } get CurrentProductTerms() { const formValue = { ...this.CurrentProductTermsForm.value }; - const dateFormat = this.settingsService.dateFormat; - - if (formValue.lastInterestDate instanceof Date) { - formValue.lastInterestDate = this.dateUtils.formatDate(formValue.lastInterestDate, dateFormat) || ''; + if (!this.isOverdraftAllowed) { + delete formValue.nominalAnnualInterestRateOverdraft; + delete formValue.minOverdraftForInterestCalculation; } - - return { ...formValue }; + return formValue; } } diff --git a/src/app/products/current-products/edit-current-product/edit-current-product.component.html b/src/app/products/current-products/edit-current-product/edit-current-product.component.html index 699f0bfce..8cd4d990e 100644 --- a/src/app/products/current-products/edit-current-product/edit-current-product.component.html +++ b/src/app/products/current-products/edit-current-product/edit-current-product.component.html @@ -50,6 +50,7 @@
diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html index c2904dd05..f68cdbf07 100644 --- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html +++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html @@ -257,16 +257,10 @@
} - @if (currentProduct.frequencyFixDay) { + @if (currentProduct.interestCalculationDaysInYearType?.id) {
- Frequency Fix Day - {{ currentProduct.frequencyFixDay }} -
- } - @if (currentProduct.lastInterestDate) { -
- Last Interest Date - {{ currentProduct.lastInterestDate | dateFormat }} + Days in Year + {{ currentProduct.interestCalculationDaysInYearType.id }} Days
}
@@ -396,37 +390,57 @@ {{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }} - {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }} + ₹ {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}
} @if (currentProduct.nominalAnnualInterestRateOverdraft) {
{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }} - {{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % + {{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a.
} @if (currentProduct.overdraftLimit) {
{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }} - {{ currentProduct.overdraftLimit | formatNumber }} + ₹ {{ currentProduct.overdraftLimit | formatNumber }}
} @if (currentProduct.allowOverdraft) { - @if (currentProduct.avgBalanceCalcPeriod) { + @if (currentProduct.odMinLimit) {
- Average Balance Calculation Period - {{ currentProduct.avgBalanceCalcPeriod }} + Minimum OD Limit + ₹ {{ currentProduct.odMinLimit | formatNumber }} +
+ } + @if (currentProduct.odSecurityType) { +
+ OD Security Type + {{ currentProduct.odSecurityType | titlecase }}
} @if (currentProduct.validForPeriod) {
- Valid for Period + Sanction Validity {{ currentProduct.validForPeriod }}
} +
+ Excess Drawing Allowed + {{ currentProduct.odExcessAllowed | yesNo }} +
+ @if (currentProduct.odExcessAllowed) { +
+ Maximum Excess + {{ currentProduct.odExcessLimitPercent | formatNumber }} % of limit +
+
+ Penal Charge on Excess + {{ currentProduct.odPenalRate | formatNumber }} % p.a. +
+ } @if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) {
- OD Account Closed with Zero Balance in Day Closing + End OD Once Fully Repaid {{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}
} @@ -588,10 +602,34 @@

{{ 'labels.heading.Accounting' | translate }}

- + +
+
+ Type + {{ currentProduct.accountingRule?.value || 'Cash' }} +
+
+ Cash GL (deposit / withdrawal) + Branch cash GL — Financial Activity: Cash at Main Vault / Teller +
+ @for (gl of currentAccountingRows; track gl.label) { +
+ {{ gl.label }} + + @if (gl.account) { + ({{ gl.account.glCode }}) {{ gl.account.name }} + } @else { + — + } + +
+ } +
diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts index 40c634e85..3b9f19df2 100644 --- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts +++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts @@ -22,7 +22,6 @@ import { MatRowDef, MatRow } from '@angular/material/table'; -import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { YesnoPipe } from '../../../../pipes/yesno.pipe'; @@ -47,7 +46,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatHeaderRow, MatRowDef, MatRow, - ViewSavingsAccountingDetailsComponent, DateFormatPipe, FormatNumberPipe, YesnoPipe @@ -102,4 +100,20 @@ export class CurrentProductGeneralTabComponent { isCashOrAccrualAccounting(): boolean { return this.accounting.isCashOrAccrualAccounting(this.currentProduct.accountingRule); } + + /** The GLs a current product maps, by the names the Accounting tab gives them. */ + get currentAccountingRows(): Array<{ label: string; account: any }> { + const m = this.currentProduct?.accountingMappings || {}; + const rows = [ + { label: 'Current Control (Liability)', account: m.savingsControlAccount }, + { label: 'Overdraft Portfolio Control (Asset)', account: m.overdraftPortfolioControl }, + { label: 'Income From Fees', account: m.incomeFromFeeAccount }, + { label: 'Income From Penalties', account: m.incomeFromPenaltyAccount }, + { label: 'Income From Interest', account: m.incomeFromInterest } + ]; + if (this.currentProduct?.isDormancyTrackingActive) { + rows.push({ label: 'Escheat Liability', account: m.escheatLiabilityAccount }); + } + return rows; + } } diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts index 921f9773e..e1004052a 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts @@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income'; +const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable'; +const FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Fixed Deposit Interest Paid'; +const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid'; + type AccountingRowConfig = { key: string; label: string; @@ -128,6 +132,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha feesReceivableAccountId: [''], penaltiesReceivableAccountId: [''], interestPayableAccountId: [''], + savingsInterestPaidAccountId: [''], escheatLiabilityId: [''], assetsOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]), @@ -158,6 +163,20 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha return ''; } + private extractNamedGlMapping( + otherGlMappings: any[], + accountName: string, + controlName: string, + options: GLAccount[] + ): any[] { + const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName); + if (match) { + const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || ''; + this.fixedDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId); + } + return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName); + } + private patchAccountingValues(mappings: any) { this.fixedDepositProductAccountingForm.patchValue({ savingsReferenceAccountId: this.extractAccountId( @@ -200,7 +219,18 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha glMappings?.liabilities || [], this.liabilityAccountOptions ); - this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions); + const fromExpenses = this.extractNamedGlMapping( + this.extractNamedGlMapping( + glMappings?.expenses || [], + FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME, + 'savingsInterestPaidAccountId', + this.expenseAccountOptions + ), + SAVINGS_INTEREST_PAID_ACCOUNT_NAME, + 'savingsInterestPaidAccountId', + this.expenseAccountOptions + ); + this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions); this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions); } } @@ -266,10 +296,26 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha })) .filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode); + const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => { + const glAccountId = this.fixedDepositProductAccountingForm.get(controlName)?.value; + if (!glAccountId) { + return []; + } + const glCode = options.find((o) => o.id === glAccountId)?.glCode || ''; + return glCode ? [{ accountName, glCode }] : []; + }; + const otherGlMappings = { assets: map('assetsOtherGlMappings', this.assetAccountOptions), liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions), - expenses: map('expensesOtherGlMappings', this.expenseAccountOptions), + expenses: [ + ...namedAsOtherGlMapping( + 'savingsInterestPaidAccountId', + FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME, + this.expenseAccountOptions + ), + ...map('expensesOtherGlMappings', this.expenseAccountOptions) + ], income: map('incomeOtherGlMappings', this.incomeAccountOptions) }; return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null; @@ -308,6 +354,11 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha setRequired('incomeFromPenaltyAccountId', cashOrAccrual); setRequired('feesReceivableAccountId', accrualOnly); setRequired('penaltiesReceivableAccountId', accrualOnly); + // Required only on accrual, where there is no posting without it. On cash it stays optional but + // is offered, because interest posting routes through it when the product names one (see + // AccountingProcessorHelper.createInterestPostingJournalEntriesForSavings): the payable carries + // interest declared but not yet credited, and a product that names none collapses straight to + // Savings Control instead. setRequired('interestPayableAccountId', accrualOnly); setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled()); @@ -326,8 +377,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha rows: [ { key: 'savingsReferenceAccountId', - label: 'Savings Reference Account', - inputLabel: 'Savings reference account', + label: 'Fixed Deposit Reference Account', + inputLabel: 'Fixed deposit reference account', controlName: 'savingsReferenceAccountId', options: this.assetAccountOptions, required: true @@ -360,8 +411,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha rows: [ { key: 'savingsControlAccountId', - label: 'Savings Control Account', - inputLabel: 'Savings control account', + label: 'Fixed Deposit Control Account', + inputLabel: 'Fixed deposit control account', controlName: 'savingsControlAccountId', options: this.liabilityAccountOptions, required: true @@ -375,12 +426,16 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha required: true }, { + // Offered on cash accounting too, not only accrual: interest posting routes through + // this account whichever basis the product uses, so that interest declared but not yet + // credited to the member reads as the bank's own liability rather than appearing only + // when it is paid. Optional on cash - a product naming none posts straight to Savings + // Control as before. key: 'interestPayableAccountId', label: 'Interest Payable Account', inputLabel: 'Interest payable account', controlName: 'interestPayableAccountId', - options: this.liabilityAccountOptions, - accrualOnly: true + options: this.liabilityAccountOptions }, { key: 'escheatLiabilityId', @@ -402,11 +457,18 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha rows: [ { key: 'interestOnSavingsAccountId', - label: 'Interest On Savings Account', - inputLabel: 'Interest on savings account', + label: 'Interest On Fixed Deposits', + inputLabel: 'Interest on fixed deposits', controlName: 'interestOnSavingsAccountId', options: this.expenseAccountOptions, required: true + }, + { + key: 'savingsInterestPaidAccountId', + label: 'Fixed Deposit Interest Paid', + inputLabel: 'Fixed deposit interest paid', + controlName: 'savingsInterestPaidAccountId', + options: this.expenseAccountOptions } ], otherMappingsControlName: 'expensesOtherGlMappings', @@ -447,6 +509,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha liabilitiesOtherGlMappings, expensesOtherGlMappings, incomeOtherGlMappings, + savingsInterestPaidAccountId, ...accounting } = this.fixedDepositProductAccountingForm.value; const otherGlMappings = this.getOtherGlMappings(); diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html index e92b052fd..8a6431b90 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html @@ -481,6 +481,7 @@ } diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html index 239c749bd..504d06752 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html @@ -34,6 +34,35 @@ + +
+
+ + Renewal +
+
+
+ ℹ️ + + Leave blank for an FD on this product to keep renewing instantly, the same night it matures, as before. Set a + number of days and a matured "auto renew" FD instead waits on the FD Dashboard for that many days for staff to + renew it manually -- backdated to the original maturity date within that window, or from the day it is + actually renewed (with gap-period interest at the linked Savings account's rate) once the window has passed. + +
+
+ + Renewal Grace Period + + Days + @if (fixedDepositProductTermsForm.get('renewalGracePeriodDays').hasError('min')) { + Renewal Grace Period must be zero or greater + } + +
+
+
+
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts index 11061126a..d1abb4ad6 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts @@ -85,12 +85,21 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges : (this.interestCalculationTypeOptionData?.[0]?.id ?? null); this.fixedDepositProductTermsForm.patchValue({ - minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? 1, - minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? defaultPeriodTypeId, + // Hidden, unedited-by-anyone fields. On EDIT, left as the product's own saved value with no + // forced fallback (like their max-/in-multiples-of- siblings already correctly are), or a + // checker's "Proposed changes" review showed these as changed on every unrelated edit, never + // anything a maker touched. On CREATE there is no saved value yet, and the backend requires + // minDepositTerm -- default it (and its unit) here same as before, since the field is hidden + // and no one gets a chance to fill it in themselves. + minDepositTerm: isEditing ? (this.fixedDepositProductsTemplate.minDepositTerm ?? '') : 1, + minDepositTermTypeId: isEditing + ? (this.fixedDepositProductsTemplate.minDepositTermType?.id ?? '') + : defaultPeriodTypeId, maxDepositTerm: this.fixedDepositProductsTemplate.maxDepositTerm ?? '', maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '', inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '', inMultiplesOfDepositTermTypeId: this.fixedDepositProductsTemplate.inMultiplesOfDepositTermType?.id ?? '', + renewalGracePeriodDays: this.fixedDepositProductsTemplate.renewalGracePeriodDays ?? '', minDepositAmount: this.fixedDepositProductsTemplate.minDepositAmount, maxDepositAmount: this.fixedDepositProductsTemplate.maxDepositAmount, nominalAnnualInterestRate: this.fixedDepositProductsTemplate.nominalAnnualInterestRate ?? 0, @@ -171,6 +180,13 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges maxDepositTermTypeId: [''], inMultiplesOfDepositTerm: [''], inMultiplesOfDepositTermTypeId: [''], + // Days after maturity a depositor can still walk in and have their FD renewed backdated to the + // original maturity date, at the currently active rate -- blank/0 keeps today's instant, + // same-night auto-renewal (see FD Dashboard for the manual renewal this enables). + renewalGracePeriodDays: [ + '', + Validators.min(0) + ], minDepositAmount: [ '', Validators.min(0) diff --git a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html index cb3324ea9..458eece5b 100644 --- a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html +++ b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html @@ -607,6 +607,7 @@ } diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts index 3ceaa248a..6d3332921 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts @@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income'; +const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable'; +const RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Recurring Deposit Interest Paid'; +const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid'; + type AccountingRowConfig = { key: string; label: string; @@ -122,6 +126,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { feesReceivableAccountId: [''], penaltiesReceivableAccountId: [''], interestPayableAccountId: [''], + savingsInterestPaidAccountId: [''], escheatLiabilityId: [''], assetsOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]), @@ -136,6 +141,20 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { }); } + private extractNamedGlMapping( + otherGlMappings: any[], + accountName: string, + controlName: string, + options: GLAccount[] + ): any[] { + const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName); + if (match) { + const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || ''; + this.recurringDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId); + } + return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName); + } + private patchAccountingValues(mappings: any) { this.recurringDepositProductAccountingForm.patchValue({ savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '', @@ -158,7 +177,18 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { glMappings?.liabilities || [], this.liabilityAccountOptions ); - this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions); + const fromExpenses = this.extractNamedGlMapping( + this.extractNamedGlMapping( + glMappings?.expenses || [], + RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME, + 'savingsInterestPaidAccountId', + this.expenseAccountOptions + ), + SAVINGS_INTEREST_PAID_ACCOUNT_NAME, + 'savingsInterestPaidAccountId', + this.expenseAccountOptions + ); + this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions); this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions); } } @@ -224,10 +254,26 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { })) .filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode); + const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => { + const glAccountId = this.recurringDepositProductAccountingForm.get(controlName)?.value; + if (!glAccountId) { + return []; + } + const glCode = options.find((o) => o.id === glAccountId)?.glCode || ''; + return glCode ? [{ accountName, glCode }] : []; + }; + const otherGlMappings = { assets: map('assetsOtherGlMappings', this.assetAccountOptions), liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions), - expenses: map('expensesOtherGlMappings', this.expenseAccountOptions), + expenses: [ + ...namedAsOtherGlMapping( + 'savingsInterestPaidAccountId', + RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME, + this.expenseAccountOptions + ), + ...map('expensesOtherGlMappings', this.expenseAccountOptions) + ], income: map('incomeOtherGlMappings', this.incomeAccountOptions) }; return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null; @@ -360,11 +406,18 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { rows: [ { key: 'interestOnSavingsAccountId', - label: 'Interest On Savings Account', - inputLabel: 'Interest on savings account', + label: 'Interest On Recurring Deposits', + inputLabel: 'Interest on recurring deposits', controlName: 'interestOnSavingsAccountId', options: this.expenseAccountOptions, required: true + }, + { + key: 'savingsInterestPaidAccountId', + label: 'Recurring Deposit Interest Paid', + inputLabel: 'Recurring deposit interest paid', + controlName: 'savingsInterestPaidAccountId', + options: this.expenseAccountOptions } ], otherMappingsControlName: 'expensesOtherGlMappings', @@ -405,6 +458,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { liabilitiesOtherGlMappings, expensesOtherGlMappings, incomeOtherGlMappings, + savingsInterestPaidAccountId, ...accounting } = this.recurringDepositProductAccountingForm.value; const otherGlMappings = this.getOtherGlMappings(); diff --git a/src/app/products/saving-products/create-saving-product/create-saving-product.component.html b/src/app/products/saving-products/create-saving-product/create-saving-product.component.html index 867619fa8..f60a295f3 100644 --- a/src/app/products/saving-products/create-saving-product/create-saving-product.component.html +++ b/src/app/products/saving-products/create-saving-product/create-saving-product.component.html @@ -144,7 +144,7 @@ } @if (savingProductFormValid && selectedTabIndex === totalTabs - 1) { - diff --git a/src/app/products/saving-products/create-saving-product/create-saving-product.component.ts b/src/app/products/saving-products/create-saving-product/create-saving-product.component.ts index 942b0f195..7207b8d78 100644 --- a/src/app/products/saving-products/create-saving-product/create-saving-product.component.ts +++ b/src/app/products/saving-products/create-saving-product/create-saving-product.component.ts @@ -70,6 +70,10 @@ export class CreateSavingProductComponent { savingProductsTemplate: any; accountingRuleData: string[] = []; selectedTabIndex = 0; + /** Guards against repeat clicks on Submit: the product name is unique in the database, so a + * second request for the same form fails with a duplicate key error even though the first one + * created the product. */ + submitting = false; /** * @param {ActivatedRoute} route Activated Route. @@ -162,8 +166,11 @@ export class CreateSavingProductComponent { } submit() { + if (this.submitting) { + return; + } + this.submitting = true; const savingProduct = this.prepareSavingProductPayload(); - console.log('Saving product submit payload:', savingProduct); this.productsService.createSavingProduct(savingProduct).subscribe({ next: (response: any) => { if (response?.rollbackTransaction) { @@ -181,6 +188,11 @@ export class CreateSavingProductComponent { { relativeTo: this.route } ); } + }, + error: () => { + // the error itself is reported by the global error handler; just let the user correct the + // form and try again + this.submitting = false; } }); } diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts index 9f45429ff..a46c736b8 100644 --- a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts +++ b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts @@ -88,6 +88,8 @@ export class SavingProductAccountingStepComponent implements OnInit { liabilityAccountOptions: GLAccount[] = []; expenseAccountOptions: GLAccount[] = []; incomeAccountOptions: GLAccount[] = []; + /** Savings Interest Paid sits under Expenses and offers expense GLs; it nets to nil at each posting. */ + paidAccountOptions: GLAccount[] = []; assetAccountOptions: GLAccount[] = []; paymentTypeOptions: any[] = []; /** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account @@ -114,6 +116,7 @@ export class SavingProductAccountingStepComponent implements OnInit { this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || []; this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || []; + this.paidAccountOptions = this.expenseAccountOptions; this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || []; this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions); this.buildAccountingSectionConfigs(); @@ -201,26 +204,30 @@ export class SavingProductAccountingStepComponent implements OnInit { // Backend serializes as 'otherGlMappingsMap'; fall back to 'otherGlMappings' for compatibility const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings; this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions); - this.setOtherGlMappings( - 'liabilitiesOtherGlMappings', + // Payable is a liability row, saved under liabilities; Paid an expense row, saved under + // expenses. Products saved before either move carry them in the other bucket, so both buckets + // are read and the named entries are stripped out of either one's "Other" rows. + const fromLiabilities = this.extractNamedGlMapping( this.extractNamedGlMapping( glMappings?.liabilities || [], SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME, 'savingsInterestPayableAccountId', this.liabilityAccountOptions ), - this.liabilityAccountOptions + SAVINGS_INTEREST_PAID_ACCOUNT_NAME, + 'savingsInterestPaidAccountId', + this.paidAccountOptions ); - this.setOtherGlMappings( - 'expensesOtherGlMappings', - this.extractNamedGlMapping( - glMappings?.expenses || [], - SAVINGS_INTEREST_PAID_ACCOUNT_NAME, - 'savingsInterestPaidAccountId', - this.expenseAccountOptions + this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions); + const fromExpenses = this.extractNamedGlMapping( + (glMappings?.expenses || []).filter( + (mapping: any) => mapping?.accountName !== SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME ), - this.expenseAccountOptions + SAVINGS_INTEREST_PAID_ACCOUNT_NAME, + 'savingsInterestPaidAccountId', + this.paidAccountOptions ); + this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions); this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions); } } @@ -336,11 +343,13 @@ export class SavingProductAccountingStepComponent implements OnInit { ), ...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions) ], + // The backend finds Savings Interest Paid by name in any bucket; it sits under expenses + // because that is where the screen shows it. expenses: [ ...namedAsOtherGlMapping( 'savingsInterestPaidAccountId', SAVINGS_INTEREST_PAID_ACCOUNT_NAME, - this.expenseAccountOptions + this.paidAccountOptions ), ...mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions) ], @@ -378,14 +387,16 @@ export class SavingProductAccountingStepComponent implements OnInit { if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) { addControl('savingsReferenceAccountId', true); addControl('savingsControlAccountId', true); + // The interest expense: every interest posting debits it for the product's total. addControl('interestOnSavingsAccountId', true); addControl('incomeFromFeeAccountId', true); addControl('incomeFromPenaltyAccountId', true); // Savings Interest Payable / Paid are offered under Cash too, but the backend only persists the // core mappings for CASH_BASED, so these two ride along in otherGlMappings (see getOtherGlMappings) - // rather than as named accounting params, which would be silently dropped. Optional by design. - addControl('savingsInterestPayableAccountId', false); - addControl('savingsInterestPaidAccountId', false); + // rather than as named accounting params, which would be silently dropped. + // Required: interest posting refuses a product without them rather than skipping their legs. + addControl('savingsInterestPayableAccountId', true); + addControl('savingsInterestPaidAccountId', true); // A real accounting param, unlike the two above, so it rides out as itself rather than // inside otherGlMappings. Optional: a product saved without one still closes. addControl('roundingAdjustmentAccountId', false); @@ -522,11 +533,14 @@ export class SavingProductAccountingStepComponent implements OnInit { required: true }, { + // Credited with the product's total interest at posting and drawn straight back down, + // so it closes each posting at nil. key: 'savingsInterestPayableAccountId', label: 'Savings Interest Payable', inputLabel: 'Savings interest payable', controlName: 'savingsInterestPayableAccountId', - options: this.liabilityAccountOptions + options: this.liabilityAccountOptions, + required: true }, { key: 'interestPayableAccountId', @@ -555,6 +569,7 @@ export class SavingProductAccountingStepComponent implements OnInit { pillClass: 'amber', rows: [ { + // The interest cost: debited for the product's total at every posting. key: 'interestOnSavingsAccountId', label: 'Interest On Savings Account', inputLabel: 'Interest on savings account', @@ -563,11 +578,13 @@ export class SavingProductAccountingStepComponent implements OnInit { required: true }, { + // A pass-through between the payable and the members that closes each posting at nil. key: 'savingsInterestPaidAccountId', label: 'Savings Interest Paid', inputLabel: 'Savings interest paid', controlName: 'savingsInterestPaidAccountId', - options: this.expenseAccountOptions + options: this.paidAccountOptions, + required: true }, { // Cash has no paise, so a closing balance of 9,883.79 is handed over as 9,883 and the diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.html b/src/app/rbi-circulars/form9-return/form9-return.component.html new file mode 100644 index 000000000..98878e3e0 --- /dev/null +++ b/src/app/rbi-circulars/form9-return/form9-return.component.html @@ -0,0 +1,261 @@ + + +
+ + @if (awaitingDate) { +
+

Form IX — Return on Assets and Liabilities

+

+ Return code R408, filed monthly. Every figure is a closing balance as at the month end, in rupees thousands. +

+ +
+ + Reporting date + + + +
+ + @if (chosenMonth) { +

+ Month: {{ chosenMonth }} +

+ } + +
+ Jump to: + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } +
+ } @else if (loading) { +
Computing from the ledger…
+ } @else if (errorMsg) { +
{{ errorMsg }}
+ } @else if (data) { +
+ + As at {{ data.reportingDate }} · {{ data.unit }} + + + + +
+ +
+
+
{{ data.formName }}
+
{{ data.statutoryReference }} · Return code {{ data.returnCode }}
+
As at {{ data.reportingDate }} · {{ data.unit }}
+
+ + +
+
+ Total Liabilities + {{ amount(data.reconciliation.totalLiabilities) }} +
+
+ Total Assets + {{ amount(data.reconciliation.totalAssets) }} +
+
+ Difference + + {{ amount(data.reconciliation.difference) }} + + + {{ + data.reconciliation.balanced + ? 'The two sides agree' + : rupees(data.reconciliation.differenceInRupees) + ' in rupees' + }} + +
+
+ + @if (data.caveats.length) { +
+ Check before filing. +
    + @for (caveat of data.caveats; track caveat) { +
  • {{ caveat }}
  • + } +
+
+ } + + + + + + + + + + + @for (line of data.liabilityLines; track line.configKey) { + + + + + + @for (account of line.accounts; track $index) { + + + + + } + } + + + + + + + + + +
A. Liabilities in IndiaRs. in Thousands
+ {{ line.label }} + @if (!line.configured) { + Not mapped + } + + @if (line.note) { + {{ line.note }} + } + {{ amount(line.amount) }}
+ {{ account.glCode }} + {{ account.name }} + @if (account.excluded) { + excluded — {{ account.excluded }} + } + @if (isPartial(account)) { + {{ account.weightPercentage }}% share + } + {{ amount(account.balance) }}
Total Demand and Time Liabilities (4 + 5 + 6){{ amount(total('totalDemandAndTime')) }}
TOTAL LIABILITIES{{ amount(total('totalLiabilities')) }}
+ + + + + + + + + + + @for (line of data.assetLines; track line.configKey) { + + + + + + @if (line.accountsMirrorHead && line.accounts.length) { + + + + } + @for (account of line.accounts; track $index) { + + + + + } + } + + + + + +
B. Assets in IndiaRs. in Thousands
+ {{ line.label }} + @if (!line.configured) { + Not mapped + } + + @if (line.note) { + {{ line.note }} + } + {{ amount(line.amount) }}
+ As the ledger holds them — these balances carry the opposite sign and add to the figure above +
+ {{ account.glCode }} + {{ account.name }} + @if (account.excluded) { + excluded — {{ account.excluded }} + } + @if (isPartial(account)) { + {{ account.weightPercentage }}% share + } + {{ amount(account.balance) }}
TOTAL ASSETS{{ amount(total('totalAssets')) }}
+ + + @if (data.memoLines.length) { + + + + + + + + + @for (line of data.memoLines; track line.configKey) { + + + + + } + + + + + + + + + +
Stated splits and explanations — entering no totalRs. in Thousands
+ {{ line.label }} + @if (!line.configured) { + Not mapped + } + {{ amount(line.amount) }}
Percentage of overdues to total loans and advances{{ percent(total('overduePercentage')) }}
Priority sector advances as a percentage of total loans{{ percent(total('prioritySectorPercentage')) }}
+ } + +
+
AUTHORISED SIGNATORY
+
+
+ } +
diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.scss b/src/app/rbi-circulars/form9-return/form9-return.component.scss new file mode 100644 index 000000000..3a3fd42e1 --- /dev/null +++ b/src/app/rbi-circulars/form9-return/form9-return.component.scss @@ -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; + } +} diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.ts b/src/app/rbi-circulars/form9-return/form9-return.component.ts new file mode 100644 index 000000000..01608f2f9 --- /dev/null +++ b/src/app/rbi-circulars/form9-return/form9-return.component.ts @@ -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; + 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 { + 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); + } +} diff --git a/src/app/rbi-circulars/oss-live/oss-return.base.ts b/src/app/rbi-circulars/oss-live/oss-return.base.ts new file mode 100644 index 000000000..f62312051 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss-return.base.ts @@ -0,0 +1,419 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { inject } from '@angular/core'; +import { Observable, catchError, of } from 'rxjs'; +import * as ExcelJS from 'exceljs'; +import { RbiCircularsService } from '../rbi-circulars.service'; +import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook'; + +/** One GL account behind a return line. */ +export interface OssAccount { + glAccountId: number; + glCode: string; + name: string; + /** Null when the account is pinned but not countable — see `excluded`. */ + balance: number | null; + /** Set when the account is still mapped but left out: disabled, or a header. */ + excluded?: 'disabled' | 'header'; +} + +/** One line of a return, as the backend computes it. */ +export interface OssLine { + label: string; + /** Null on derived rows and headcounts — they are not mapped to accounts. */ + roleKey: string | null; + configured: boolean; + /** Null when the role has no accounts mapped. Never 0 in that case. */ + amount: number | null; + accounts: OssAccount[]; + /** Derived row, summed from the roles it names. */ + subtotal?: boolean; + /** Headcount rather than a rupee amount. */ + count?: boolean; + /** Roles a subtotal is summed from. */ + components?: string[]; +} + +/** Which sheet of the return is on screen. */ +export type OssTab = 'general' | 'figures' | 'signatory'; + +/** A `label -> value` row on the General Information sheet. */ +export interface OssGeneralInfoRow { + label: string; + value: string; + /** False when the particular is not maintained in the institution profile. */ + configured: boolean; +} + +export interface OssSignatory { + heading: string; + name: string; + designation: string; + email: string; + telephoneOffice: string; + place: string; + date: string; +} + +/** The whole return, as the backend computes it. */ +export interface OssReturn { + returnCode: string; + returnName: string; + reportingFrequency: string; + generalInfo: OssGeneralInfoRow[]; + quarter: { label: string; startDate: string; endDate: string }; + officeId: number | null; + officeName: string; + signatories: OssSignatory[]; + basis: { asOnDate?: string; fromDate?: string; toDate?: string; note: string }; + computable: boolean; + unmetRequirements: string[]; + // OSS 1 only. + liabilities?: OssLine[]; + assets?: OssLine[]; + totalLiabilities?: number; + totalAssets?: number; + balanced?: boolean; + imbalance?: number; + // OSS 2 only. + earnings?: OssLine[]; + expenses?: OssLine[]; + totalIncome?: number; + totalExpenditure?: number; + operatingProfit?: number; +} + +/** + * Shared behaviour for the live OSS returns — OSS 1 (RAL) and OSS 2 (RoE). + * + * Nothing is computed in the browser. Every figure, and the bank's own particulars, come from one + * endpoint that reads them from the ledger and the institution profile; re-deriving anything here + * would produce a second set of numbers that could disagree with the filed return. + */ +export abstract class OssReturnBase { + protected rbiService = inject(RbiCircularsService); + + /** Which return to fetch. */ + protected abstract fetch(quarterEndDate: string): Observable; + + /** Filename stem for the Excel export. */ + protected abstract readonly exportStem: string; + + /** Where this return's own GL roles are configured. */ + abstract readonly configRoute: string; + abstract readonly configLinkLabel: string; + + /** 'OSS 1' / 'OSS 2', for the heading. */ + abstract readonly returnShortName: string; + + /** + * The one fact a filer needs before reading any figure — whether the return can be filed as it + * stands. Rendered as HTML so a figure inside it can be emphasised. + */ + abstract get headline(): string; + + /** Whether the headline states a problem, which decides how the banner reads. */ + abstract get headlineIsBad(): boolean; + + /** Sheets of the return, in the order RBI prints them. */ + readonly tabs: { id: OssTab; label: string; icon: string }[] = [ + { id: 'general', label: 'General Information', icon: 'info-circle' }, + { id: 'figures', label: 'Figures', icon: 'university' }, + { id: 'signatory', label: 'Signatory', icon: 'check-circle' } + ]; + + activeTab: OssTab = 'general'; + + showTab(tab: OssTab): void { + this.activeTab = tab; + } + + /** Bank particulars for the header, read back from the General Information sheet. */ + private info(label: string): string { + return this.data?.generalInfo.find((g) => g.label === label)?.value ?? ''; + } + + get bankName(): string { + return this.info('Bank Name'); + } + + get bankCode(): string { + return this.info('Bank Code'); + } + + /** The signatory block as caption/value pairs, in the order every OSS return prints them. */ + signatoryFields(signatory: OssSignatory): { caption: string; value: string }[] { + return [ + { caption: 'Name', value: signatory.name }, + { caption: 'Designation', value: signatory.designation }, + { caption: 'e-mail ID', value: signatory.email }, + { caption: 'Tele. No. (O)', value: signatory.telephoneOffice }, + { caption: 'Place', value: signatory.place }, + { caption: 'Date', value: signatory.date } + ]; + } + + data: OssReturn | null = null; + loading = true; + errorMsg = ''; + generating = false; + + /** Any date in the quarter being reported; the backend resolves it to that quarter. */ + quarterDate = new Date().toISOString().slice(0, 10); + + toastMsg = ''; + toastVisible = false; + private toastTimer: any; + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.fetch(this.quarterDate) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute this return from the ledger.'; + return of(null); + }) + ) + .subscribe((res: OssReturn | null) => { + this.loading = false; + this.data = res; + }); + } + + onDateChange(): void { + this.load(); + } + + /** Jump to the quarter containing today. */ + resetToToday(): void { + this.quarterDate = new Date().toISOString().slice(0, 10); + this.load(); + } + + // ── presentation helpers ── + + amount(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + count(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN'); + } + + protected toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + } + + // ── Excel export ── + + /** + * Exports the return as it stands: the same figures the screen shows, the basis, and the + * account-level breakdown, so a reviewer can trace any total back to the accounts behind it. + */ + async exportWorkbook(): Promise { + if (!this.data) { + return; + } + this.generating = true; + try { + const wb = newWorkbook(); + this.buildGeneralInfoSheet(wb); + this.buildFiguresSheet(wb); + this.buildSignatorySheet(wb); + + await downloadWorkbook(wb, `${this.exportStem}_${this.data.quarter.endDate}.xlsx`); + this.toast('Workbook downloaded.'); + } finally { + this.generating = false; + } + } + + private buildGeneralInfoSheet(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('General Information'); + sheet.columns = [ + { width: 34 }, + { width: 52 }]; + addBanner(sheet, 'General Information', 'B'); + + const start = sheet.rowCount + 1; + for (const row of this.data!.generalInfo) { + const r = sheet.addRow([ + row.label, + row.value + ]); + r.getCell(1).font = { bold: true }; + r.getCell(1).fill = solidFill(XL.keyCell); + // A particular that is not maintained is flagged rather than left looking filled in. + if (!row.configured) { + r.getCell(2).font = { italic: true, color: { argb: 'FFB06000' } }; + } + } + const scope = sheet.addRow([ + 'Office scope', + this.data!.officeName + ]); + scope.getCell(1).font = { bold: true }; + scope.getCell(1).fill = solidFill(XL.keyCell); + boxAllCells(sheet, start, sheet.rowCount, 2); + } + + /** Each section of the return, with its account-level breakdown under every mapped line. */ + protected abstract sections(): { title: string; unit: string; lines: OssLine[] }[]; + + /** Derived totals printed under the figures, in the order the return states them. */ + protected abstract totals(): { label: string; value: number | null | undefined }[]; + + private buildFiguresSheet(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Figures'); + sheet.columns = [ + { width: 66 }, + { width: 22 }]; + addBanner(sheet, `${this.data!.returnName} — ${this.data!.quarter.label}`, 'B'); + + const noteRow = sheet.addRow([this.data!.basis.note]); + sheet.mergeCells(noteRow.number, 1, noteRow.number, 2); + noteRow.alignment = { wrapText: true }; + noteRow.font = { size: 9, italic: true }; + sheet.addRow([]); + + for (const section of this.sections()) { + const start = sheet.rowCount + 1; + const head = sheet.addRow([ + section.title, + section.unit + ]); + head.font = { bold: true }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + for (const line of section.lines) { + const row = sheet.addRow([ + line.label, + line.configured ? line.amount : 'Not configured' + ]); + row.getCell(1).alignment = { wrapText: true }; + row.getCell(2).numFmt = line.count ? '#,##0' : '#,##0.00'; + row.getCell(2).alignment = { horizontal: 'right' }; + if (line.subtotal) { + row.font = { bold: true }; + row.getCell(2).fill = solidFill(XL.derivedBlue); + } + + // An excluded account is named with its reason rather than dropped: the workbook is what + // gets filed, so it must not read as complete when the figure is short by it. + for (const account of line.accounts) { + const sub = sheet.addRow([ + ` ${account.glCode} — ${account.name}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`, + account.excluded ? 'Excluded' : account.balance + ]); + sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } }; + sub.getCell(2).numFmt = '#,##0.00'; + sub.getCell(2).alignment = { horizontal: 'right' }; + } + } + boxAllCells(sheet, start, sheet.rowCount, 2); + sheet.addRow([]); + } + + const totalsStart = sheet.rowCount + 1; + for (const total of this.totals()) { + const row = sheet.addRow([ + total.label, + total.value ?? null + ]); + row.font = { bold: true }; + row.getCell(2).numFmt = '#,##0.00'; + row.getCell(2).alignment = { horizontal: 'right' }; + row.getCell(2).fill = solidFill(XL.derivedBlue); + } + boxAllCells(sheet, totalsStart, sheet.rowCount, 2); + + // Caveats travel with the workbook, not only the screen: a return with unmapped roles is + // arithmetically right for what is mapped and still must not be filed as complete. + if (this.data!.unmetRequirements.length) { + sheet.addRow([]); + for (const reason of this.data!.unmetRequirements) { + const row = sheet.addRow([`Check before filing: ${reason}`]); + sheet.mergeCells(row.number, 1, row.number, 2); + row.font = { bold: true, size: 9 }; + row.alignment = { wrapText: true }; + } + } + + sheet.views = [{ state: 'frozen', ySplit: 3 }]; + } + + private buildSignatorySheet(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Signatory'); + sheet.columns = [ + { width: 26 }, + { width: 52 }]; + addBanner(sheet, 'Signatory Information', 'B'); + + for (const signatory of this.data!.signatories) { + const start = sheet.rowCount + 1; + const heading = sheet.addRow([signatory.heading]); + sheet.mergeCells(heading.number, 1, heading.number, 2); + heading.font = { bold: true }; + heading.getCell(1).fill = solidFill(XL.headerGrey); + + for (const [ + caption, + value + ] of [ + [ + 'Name', + signatory.name + ], + [ + 'Designation', + signatory.designation + ], + [ + 'e-mail ID', + signatory.email + ], + [ + 'Tele. No. (O)', + signatory.telephoneOffice + ], + [ + 'Place', + signatory.place + ], + [ + 'Date', + signatory.date + ] + ]) { + const row = sheet.addRow([ + caption, + value || 'Not set in Institution Profile' + ]); + row.getCell(1).font = { bold: true }; + row.getCell(1).fill = solidFill(XL.keyCell); + if (!value) { + row.getCell(2).font = { italic: true, color: { argb: 'FFB06000' } }; + } + } + boxAllCells(sheet, start, sheet.rowCount, 2); + sheet.addRow([]); + } + } +} diff --git a/src/app/rbi-circulars/oss-live/oss-return.component.html b/src/app/rbi-circulars/oss-live/oss-return.component.html new file mode 100644 index 000000000..e087afcb7 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss-return.component.html @@ -0,0 +1,200 @@ + + +
+ @if (loading) { +
Computing from the ledger…
+ } @else if (errorMsg) { +
+ + {{ errorMsg }} +
+ } @else if (data) { + +
+
+ +
+

{{ returnShortName }} — {{ data.returnName }}

+
+ {{ bankName }} · Bank Code {{ bankCode }} · Return Code + {{ data.returnCode }} · Quarter ending {{ data.quarter.endDate }} +
+
+
+
+ {{ data.quarter.label }} + + + +
+
+ + +
+ + +
+ + +
+ @for (t of tabs; track t.id) { + + } +
+ + + @if (data.unmetRequirements.length) { +
+ } + + + @if (activeTab === 'general') { +
+
General Information
+
+ + + @for (item of data.generalInfo; track item.label) { + + + + + } + + + + + +
{{ item.label }}{{ item.value }}
Office scope{{ data.officeName }}
+
+

{{ data.basis.note }}

+
+ } + + + @if (activeTab === 'figures') { +
+ @for (section of sections(); track section.title) { +
{{ section.title }}
+
+ + + + + + + + + @for (line of section.lines; track line.label) { + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + } + } + + @for (total of totals(); track total.label) { + + + + + } + +
{{ section.title }}{{ section.unit }}
{{ line.label }} + @if (!line.configured) { + Not configured + } @else if (line.count) { + {{ count(line.amount) }} + } @else { + {{ amount(line.amount) }} + } +
+ {{ account.glCode }} + {{ account.name }} + @if (account.excluded) { + excluded — {{ account.excluded }} + } + {{ amount(account.balance) }}
{{ total.label }}{{ amount(total.value) }}
+
+ } +
+ } + + + @if (activeTab === 'signatory') { +
+
Signatory Information
+ @for (signatory of data.signatories; track signatory.heading) { +
+
{{ signatory.heading }}
+ + + @for (field of signatoryFields(signatory); track field.caption) { + + + + + } + +
{{ field.caption }} + {{ field.value || 'Not set in Institution Profile' }} +
+
+ } +
+ } + } + + @if (toastVisible) { +
{{ toastMsg }}
+ } +
diff --git a/src/app/rbi-circulars/oss-live/oss1-live.component.ts b/src/app/rbi-circulars/oss-live/oss1-live.component.ts new file mode 100644 index 000000000..c7275be99 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss1-live.component.ts @@ -0,0 +1,106 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Observable } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OssLine, OssReturnBase } from './oss-return.base'; + +/** + * OSS 1 — Return on Assets and Liabilities (RAL), return code R070. + * + * The bank's balance sheet restated on RBI's line structure, every figure read from the ledger as + * at the quarter end. Liabilities must equal assets; where they do not, the return says so rather + * than hiding the difference, because an imbalance is a real condition of the books that the filer + * has to see before filing. + */ +@Component({ + selector: 'mifosx-oss1-live', + templateUrl: './oss-return.component.html', + styleUrls: ['../oss-return.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + FormsModule + ] +}) +export class Oss1LiveComponent extends OssReturnBase implements OnInit { + protected readonly exportStem = 'RBI_OSS1_RAL'; + readonly configRoute = '/organization/oss1-gl-config'; + readonly configLinkLabel = 'Open OSS 1 GL Configuration'; + readonly returnShortName = 'OSS 1'; + + /** + * Whether the two sides balance — the first thing to check on a balance sheet, and the reason + * the difference is stated rather than absorbed. + */ + get headlineIsBad(): boolean { + return this.data?.balanced === false; + } + + get headline(): string { + if (!this.data) { + return ''; + } + const liabilities = this.amount(this.data.totalLiabilities); + const assets = this.amount(this.data.totalAssets); + if (!this.headlineIsBad) { + return ( + `Total Liabilities ₹${liabilities} · ` + + `Total Assets ₹${assets} · the two sides agree` + ); + } + return ( + `Total Liabilities ₹${liabilities} · ` + + `Total Assets ₹${assets} · ` + + `OUT OF BALANCE by ₹${this.amount(Math.abs(this.data.imbalance ?? 0))} — ` + + 'resolve before filing' + ); + } + + protected fetch(quarterEndDate: string): Observable { + return this.rbiService.getOss1(quarterEndDate); + } + + ngOnInit(): void { + this.load(); + } + + protected sections(): { title: string; unit: string; lines: OssLine[] }[] { + if (!this.data) { + return []; + } + return [ + { title: 'Liabilities', unit: 'Rs in Thousands', lines: this.data.liabilities ?? [] }, + { title: 'Assets', unit: 'Rs in Thousands', lines: this.data.assets ?? [] } + ]; + } + + protected totals(): { label: string; value: number | null | undefined }[] { + if (!this.data) { + return []; + } + const totals: { label: string; value: number | null | undefined }[] = [ + { label: 'Total Liabilities', value: this.data.totalLiabilities }, + { label: 'Total Assets', value: this.data.totalAssets } + ]; + // Stated only when the two sides disagree: printing a nil difference on every balanced return + // would train a reviewer to skip the line that matters when it is not nil. + if (this.data.balanced === false) { + totals.push({ label: 'Difference (Assets − Liabilities)', value: this.data.imbalance }); + } + return totals; + } + + /** True when the two sides of the balance sheet disagree. */ + get isImbalanced(): boolean { + return this.data?.balanced === false; + } +} diff --git a/src/app/rbi-circulars/oss-live/oss2-live.component.ts b/src/app/rbi-circulars/oss-live/oss2-live.component.ts new file mode 100644 index 000000000..7e3ba8f90 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss2-live.component.ts @@ -0,0 +1,88 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Observable } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OssLine, OssReturnBase } from './oss-return.base'; + +/** + * OSS 2 — Return on Earnings (RoE), return code R071. + * + * The bank's profit and loss account on RBI's line structure. Unlike OSS 1 these lines report the + * movement over the quarter rather than a closing balance: the same accounts read as balances + * would carry forward every earlier quarter's income as well. + */ +@Component({ + selector: 'mifosx-oss2-live', + templateUrl: './oss-return.component.html', + styleUrls: ['../oss-return.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + FormsModule + ] +}) +export class Oss2LiveComponent extends OssReturnBase implements OnInit { + protected readonly exportStem = 'RBI_OSS2_RoE'; + readonly configRoute = '/organization/oss2-gl-config'; + readonly configLinkLabel = 'Open OSS 2 GL Configuration'; + readonly returnShortName = 'OSS 2'; + + /** A loss is worth flagging; it is a result to explain, not merely a number to read. */ + get headlineIsBad(): boolean { + return (this.data?.operatingProfit ?? 0) < 0; + } + + get headline(): string { + if (!this.data) { + return ''; + } + const result = this.data.operatingProfit ?? 0; + return ( + `Total Income ₹${this.amount(this.data.totalIncome)} · ` + + `Total Expenditure ₹${this.amount(this.data.totalExpenditure)} · ` + + `${result >= 0 ? 'Operating Profit' : 'Operating Loss'} ` + + `₹${this.amount(Math.abs(result))} for the quarter` + ); + } + + protected fetch(quarterEndDate: string): Observable { + return this.rbiService.getOss2(quarterEndDate); + } + + ngOnInit(): void { + this.load(); + } + + protected sections(): { title: string; unit: string; lines: OssLine[] }[] { + if (!this.data) { + return []; + } + return [ + { title: 'Earnings', unit: 'Rs in Thousands', lines: this.data.earnings ?? [] }, + { title: 'Expenditure', unit: 'Rs in Thousands', lines: this.data.expenses ?? [] } + ]; + } + + protected totals(): { label: string; value: number | null | undefined }[] { + if (!this.data) { + return []; + } + return [ + { label: 'Total Income', value: this.data.totalIncome }, + { label: 'Total Expenditure', value: this.data.totalExpenditure }, + { + label: (this.data.operatingProfit ?? 0) >= 0 ? 'Operating Profit' : 'Operating Loss', + value: this.data.operatingProfit + } + ]; + } +} diff --git a/src/app/rbi-circulars/oss-return.scss b/src/app/rbi-circulars/oss-return.scss index ade2b179c..5b4f2989b 100644 --- a/src/app/rbi-circulars/oss-return.scss +++ b/src/app/rbi-circulars/oss-return.scss @@ -567,3 +567,92 @@ td.gl-diff { grid-template-columns: 1fr; } } + +/* ── Live OSS returns (OSS 1, OSS 2) ──────────────────────────────────────── + These read their figures from the ledger rather than from a filed workbook, so they carry two + things a static return does not: a note on which accounts a line came from, and a statement of + what is not mapped. */ + +/* Caveats about the return as a whole: lines with nothing behind them. Sits above the sheets + because a blank line is a fact about the return, not about the sheet it happens to be on. */ +.oss-caveat { + background: var(--o-amber-l); + border: 1px solid #e3d27a; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; + color: var(--o-amber-d); + font-size: 12.5px; +} + +.oss-caveat-h { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.oss-caveat-lead { + margin: 6px 0 0; + line-height: 1.6; + opacity: 0.9; +} + +.oss-caveat-list { + margin: 8px 0 8px 18px; + padding: 0; + + li { + margin-bottom: 4px; + } +} + +.oss-caveat-link { + display: inline-block; + margin-top: 4px; + color: var(--o-blue); + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +/* A figure the return could not produce, or an account left out of one. Marked rather than merely + blank, so it reads as deliberately absent instead of as a nil the bank asserted. */ +.oss-unset { + color: var(--o-amber-d); + font-style: italic; + font-weight: 500; +} + +/* The GL accounts behind a line, indented under it. */ +.oss-subline { + td { + font-size: 11.5px; + color: var(--o-g500); + } + + &--excluded td { + color: var(--o-amber-d); + } +} + +.oss-gl-code { + font-family: 'Roboto Mono', monospace; + font-size: 10.5px; + background: var(--o-g50); + border: 1px solid var(--o-g200); + border-radius: 4px; + padding: 1px 5px; + margin-right: 6px; +} + +/* How the figures on a sheet were arrived at — the dates they were read on. */ +.oss-note { + margin: 10px 2px 0; + font-size: 11.5px; + color: var(--o-g500); + line-height: 1.6; +} diff --git a/src/app/rbi-circulars/oss-workbook.ts b/src/app/rbi-circulars/oss-workbook.ts index edddbe9ac..8f82d1c2f 100644 --- a/src/app/rbi-circulars/oss-workbook.ts +++ b/src/app/rbi-circulars/oss-workbook.ts @@ -27,6 +27,17 @@ export function solidFill(argb: string): ExcelJS.Fill { return { type: 'pattern', pattern: 'solid', fgColor: { argb } }; } +/** + * The diagonal hatching RBI's templates put on a cell that is waiting to be filled in by hand. + * + * It reads differently from an empty cell, and the difference matters on a statutory form: an empty + * cell says nothing was reported, hatching says someone still has to report it. The templates use + * it on the signatory block and on the particulars a bank writes in itself. + */ +export function hatchFill(): ExcelJS.Fill { + return { type: 'pattern', pattern: 'lightUp', fgColor: { argb: 'FF808080' }, bgColor: { argb: 'FFFFFFFF' } }; +} + /** Dark-blue full-width banner that heads every sheet in the RBI templates. */ export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: string): void { const row = sheet.addRow([title]); diff --git a/src/app/rbi-circulars/rbi-circulars-routing.module.ts b/src/app/rbi-circulars/rbi-circulars-routing.module.ts index 045ce0f06..650354f34 100644 --- a/src/app/rbi-circulars/rbi-circulars-routing.module.ts +++ b/src/app/rbi-circulars/rbi-circulars-routing.module.ts @@ -26,16 +26,30 @@ const routes: Routes = [ data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' } }, { + // OSS 1 computed live from the ledger. Lazy — the ExcelJS workbook builder should not weigh + // down the eager bundle. path: 'rbi/oss1', - // Lazy — the ExcelJS workbook builder should not weigh down the eager bundle. - loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent), + loadComponent: () => import('./oss-live/oss1-live.component').then((m) => m.Oss1LiveComponent), data: { title: 'OSS 1 — Return on Assets and Liabilities', breadcrumb: 'OSS 1 (RAL)' } }, { path: 'rbi/oss2', - loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent), + loadComponent: () => import('./oss-live/oss2-live.component').then((m) => m.Oss2LiveComponent), data: { title: 'OSS 2 — Return on Earnings', breadcrumb: 'OSS 2 (RoE)' } }, + { + // The filed workbooks as they stood before the returns were computed from the ledger. Kept + // reachable so a figure on a live return can be checked against what was actually filed for + // an earlier quarter; they read from static data and do not move. + path: 'rbi/oss1-filed', + loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent), + data: { title: 'OSS 1 — As filed (static)', breadcrumb: 'OSS 1 (as filed)' } + }, + { + path: 'rbi/oss2-filed', + loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent), + data: { title: 'OSS 2 — As filed (static)', breadcrumb: 'OSS 2 (as filed)' } + }, { path: 'rbi/share-capital', // Lazy for the same reason as the OSS returns — the ExcelJS workbook builder should not @@ -48,7 +62,31 @@ const routes: Routes = [ path: 'rbi/oss3', loadComponent: () => import('./oss3-raq/oss3-raq.component').then((m) => m.Oss3RaqComponent), data: { title: 'OSS 3 — Return on Asset Quality', breadcrumb: 'OSS 3 (RAQ-UCB)' } + }, + { + // CRR return, filed twice a month. Lazy like the other returns — it pulls in ExcelJS. + path: 'rbi/form1', + loadComponent: () => import('./reserve-return/form1-crr.component').then((m) => m.Form1CrrComponent), + data: { title: 'Form 1 — Cash Reserve Ratio', breadcrumb: 'Form 1 (CRR)' } + }, + // Form 9 (SLR) has no route of its own. Section 24 is settled on the statutory Form 1 at Part C, + // charged on the same liabilities Part B charges the cash reserve on. A second screen computing + // the same ratio from a month-end base disagreed with it, and both were headed Form 9 — whichever + // a filer read, they had no way to know the other said something else. + { + // Form IX — the Return on Assets and Liabilities, code R408, filed monthly. + // + // A DIFFERENT return from Form 9 (SLR) above despite the name. That one reports one ratio; + // this one reports the whole balance sheet, in rupees thousands, and its two sides must + // agree. Its own route rather than a tab on rbi/form9 for exactly that reason — they answer + // different questions and share no figures. + path: 'rbi/form9-return', + loadComponent: () => import('./form9-return/form9-return.component').then((m) => m.Form9ReturnComponent), + data: { title: 'Form IX — Return on Assets and Liabilities', breadcrumb: 'Form IX (R408)' } } + // The statutory Form 1 has no route of its own: it is a tab on rbi/form1. Both are Form 1 for + // one bank and one period, and two routes made a filer guess which held which — one could be + // read while the other was believed to be on screen. ]) ]; diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts index 33446834c..7bffbc36f 100644 --- a/src/app/rbi-circulars/rbi-circulars.service.ts +++ b/src/app/rbi-circulars/rbi-circulars.service.ts @@ -60,4 +60,204 @@ export class RbiCircularsService { getShareCapitalReturnYears(): Observable { return this.http.get('/shareproducts/capital-return/financial-years'); } + + /** + * Form 1 — CRR, computed live from the ledger for the fortnight the reporting date falls in. + * + * The DTL base and the balances maintained are read as at different dates: the base lags by 45 + * days (snapped back to a reporting Friday), the holdings are as at the reporting date itself. + * The response's `basis` block states both, so the lag is auditable rather than implied. + * + * @param reportingDate yyyy-MM-dd. Defaults to the current business date on the backend. + * @param officeId Optional — scopes every figure to one office. + */ + getForm1(reportingDate?: string, baseDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (reportingDate) { + params = params.set('reportingDate', reportingDate); + } + // The base date can be given outright. The 45-day lag lands where it lands, and a bank reading + // an earlier fortnight — or correcting a filing — needs to name the date the base is struck at + // rather than work backwards from it. Left blank it is derived from the lag as before. + if (baseDate) { + params = params.set('baseDate', baseDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/form1', { params }); + } + + /** + * Form 9 — SLR, computed live on the month-end position. Any date within a month resolves to that + * month's end, since the return reports the position at close. + */ + getForm9(reportingDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (reportingDate) { + params = params.set('reportingDate', reportingDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/form9', { params }); + } + + /** + * Form 9 in the shape it is filed — the statutory statement of compliance with section 24. + * + * A different statement from `getForm9`, not a reformatting: three dated columns rather than one, + * and the ratio charged on the last Friday of the second preceding fortnight rather than on the + * month end itself. + */ + getForm9Statutory(reportingDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (reportingDate) { + params = params.set('reportingDate', reportingDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/form9-statutory', { params }); + } + + /** + * Form IX — the Return on Assets and Liabilities a co-operative bank files monthly, code R408. + * + * A different return from `getForm9` despite the name: that one reports the SLR ratio, this one + * reports the whole balance sheet. Every figure comes back in rupees thousands, with the rupee + * figure alongside it so a line can still be reconciled against the ledger. + * + * @param reportingDate yyyy-MM-dd — any date in the month; the return reports that month's end. + */ + getForm9Return(reportingDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (reportingDate) { + params = params.set('reportingDate', reportingDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/form9-return', { params }); + } + + /** + * OSS 1 — Return on Assets and Liabilities, computed live from the ledger. + * + * Every figure is a closing balance as at the quarter end; the bank's own particulars come from + * the institution profile. Any date inside a quarter resolves to that quarter, so a caller never + * has to know which day a quarter ends on. + * + * @param quarterEndDate yyyy-MM-dd — any date in the quarter. Defaults to the business date. + * @param officeId Optional — scopes every figure to one office. + */ + getOss1(quarterEndDate?: string, officeId?: number): Observable { + return this.http.get('/ossreturn/oss1', { params: this.ossParams(quarterEndDate, officeId) }); + } + + /** + * OSS 2 — Return on Earnings, computed live from the ledger. + * + * Earnings lines report the movement over the quarter rather than a closing balance, so the same + * accounts read as balances would carry forward every earlier quarter's income as well. + */ + getOss2(quarterEndDate?: string, officeId?: number): Observable { + return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) }); + } + + /** + * Form 1 as a co-operative bank files it — Banking Regulation (Co-operative Societies) Rules + * 1966, Rule 5, sections 18(1) and 24(3). + * + * A different statement from `getForm1`, not a reformatting: three columns rather than one, both + * ratios settled on the same liabilities, and those liabilities taken from the last Friday of the + * second preceding fortnight rather than a 45-day lag. + */ + getForm1Statutory( + reportingDate?: string, + firstColumnDate?: string, + secondColumnDate?: string, + officeId?: number, + firstBaseDate?: string, + secondBaseDate?: string + ): Observable { + let params = new HttpParams(); + if (reportingDate) { + params = params.set('reportingDate', reportingDate); + } + // Both fortnight closes can be given outright. A bank filing August wants 15/08 and 31/08 on + // the face of the form whatever today is, and one date cannot produce a pair that spans a month + // boundary or skips a fortnight — which a late or corrected filing needs. + if (firstColumnDate) { + params = params.set('firstColumnDate', firstColumnDate); + } + if (secondColumnDate) { + params = params.set('secondColumnDate', secondColumnDate); + } + // The base each column's ratios are charged on. Left blank the rules fix it — the last Friday + // of that column's second preceding fortnight — so a filer only names one where the rule + // produces a date their ledger cannot answer, such as a period before the bank migrated. + if (firstBaseDate) { + params = params.set('firstBaseDate', firstBaseDate); + } + if (secondBaseDate) { + params = params.set('secondBaseDate', secondBaseDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/form1-statutory', { params }); + } + + // ── Saved returns ── + + /** + * Returns saved earlier, newest first. A saved return is served from the snapshot taken when it + * was saved, never recomputed — a backdated entry or a changed mapping would otherwise produce a + * different statement from the one that was reviewed. + */ + getSavedReturns(formCode?: string): Observable { + let params = new HttpParams(); + if (formCode) { + params = params.set('formCode', formCode); + } + return this.http.get('/rbiformafiling', { params }); + } + + /** One saved return, with the stored snapshot, so the screen renders what was saved. */ + getSavedReturn(filingId: number): Observable { + return this.http.get(`/rbiformafiling/${filingId}`); + } + + /** Keeps the return currently on screen. Re-saving the same form and date replaces it. */ + saveReturn(formCode: string, reportingDate: string, report: any, remarks?: string): Observable { + return this.http.post('/rbiformafiling', { + formCode, + reportingDate, + report, + remarks, + locale: 'en', + dateFormat: 'yyyy-MM-dd' + }); + } + + /** Moves a saved return along its filing lifecycle; the snapshot itself is untouched. */ + updateSavedReturnStatus(filingId: number, status: string, remarks?: string): Observable { + return this.http.post(`/rbiformafiling/${filingId}/status`, { status, remarks }); + } + + deleteSavedReturn(filingId: number): Observable { + return this.http.delete(`/rbiformafiling/${filingId}`); + } + + private ossParams(quarterEndDate?: string, officeId?: number): HttpParams { + let params = new HttpParams(); + if (quarterEndDate) { + params = params.set('quarterEndDate', quarterEndDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return params; + } } diff --git a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts new file mode 100644 index 000000000..b275460dd --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts @@ -0,0 +1,147 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Observable, catchError, of } from 'rxjs'; +import * as ExcelJS from 'exceljs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook'; +import { Form1Part, Form1Statutory, ReserveReturnBase } from './reserve-return.base'; + +/** + * RBI Form 1 — Cash Reserve Ratio, filed twice a month. + * + * The return compares the CRR required on an earlier fortnight's Demand and Time Liabilities + * against the cash and bank balances held today. The two sides are deliberately read on different + * dates — see the basis panel on the screen, which states both. + * + * The statutory Form 1 — the return as a co-operative bank actually files it, three dated columns + * with both ratios settled on one base — is a tab on this same screen rather than a page of its + * own. Both are Form 1 for the same bank and the same period; splitting them across two routes + * meant a filer had to know which URL held which, and could read one while believing they were + * looking at the other. + */ +@Component({ + selector: 'mifosx-form1-crr', + templateUrl: './reserve-return.component.html', + styleUrls: [ + './reserve-return.component.scss', + './form1-statutory.component.scss' + ], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class Form1CrrComponent extends ReserveReturnBase implements OnInit { + protected readonly exportStem = 'RBI_Form1_CRR'; + // The statutory form's code, because that is what this screen saves and therefore what its saved + // list must ask for. Left as FORM_1 the list filtered on a code nothing writes any more and came + // back empty, which reads as "nothing has ever been saved" rather than as a mismatch. + protected readonly formCode = 'FORM_1_STATUTORY'; + + /** Returns filed before the screen moved to the statutory form, so they stay in the list. */ + protected override readonly legacyFormCodes = ['FORM_1']; + + override readonly hasStatutoryForm = true; + override readonly hasBaseDate = true; + + /** + * The statutory form is the only view of Form 1. + * + * There were two: a working report charging the ratio on a 45-day lag, and the statutory form + * charging it on the last Friday of the second preceding fortnight. Both were headed Form 1, both + * were computed from the same ledger, and they disagreed — because the rules they follow differ. + * One screen showing two different answers to "what is our CRR position" is worse than no screen: + * whichever a filer read, they had no way to know the other existed and said something else. What + * gets filed is the statutory form, so that is what the screen shows. + */ + override activeTab: 'report' | 'statutory' | 'saved' = 'statutory'; + + protected override fetchStatutory(): Observable { + return this.rbiService.getForm1Statutory( + this.statutoryDate, + this.statutoryFirstColumn || undefined, + this.statutorySecondColumn || undefined, + undefined, + this.statutoryFirstBase || undefined, + this.statutorySecondBase || undefined + ); + } + + protected fetch(reportingDate: string): Observable { + return this.rbiService.getForm1(reportingDate, this.baseDate || undefined); + } + + ngOnInit(): void { + // No load() here — the screen asks for a reporting date first, because which fortnight the + // return covers and which base date it reads follow from that one field. + this.loadSaved(); + } + /** + * The fortnight the chosen date falls in, shown beside the picker so a filer sees which period + * they are about to run. Fortnights close on the 15th and on the month end. + */ + get statutoryFortnight(): string { + if (!this.statutoryDate) { + return ''; + } + const d = new Date(this.statutoryDate + 'T00:00:00'); + const first = d.getDate() <= 15; + const end = first ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + const month = d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' }); + return `${first ? 1 : 16}–${end} ${month}`; + } + + /** + * Fills both columns with a whole month's fortnight closes — 15th and month end — which is the + * pair a filed return almost always carries. + */ + fillStatutoryMonth(): void { + const d = this.statutoryDate ? new Date(this.statutoryDate + 'T00:00:00') : new Date(); + const year = d.getFullYear(); + const month = d.getMonth() + 1; + const monthEnd = new Date(year, month, 0).getDate(); + this.statutoryFirstColumn = this.asDate(year, month, 15); + // Filling the CURRENT month would put a month end that has not arrived into the second column, + // and the form would be refused the moment it was run. Capped at today, so the quick fill + // always produces a pair that can actually be reported. + const filled = this.asDate(year, month, monthEnd); + this.statutorySecondColumn = filled > this.today ? this.today : filled; + this.statutoryDate = this.statutorySecondColumn; + // The bases follow the closes, so a quick fill leaves a complete form rather than two dates + // and two empty boxes the filer has to work out for themselves. + this.onStatutoryColumnChange('first'); + this.onStatutoryColumnChange('second'); + } + + /** Clears both columns and the bases that follow from them. */ + clearStatutoryColumns(): void { + this.statutoryFirstColumn = ''; + this.statutorySecondColumn = ''; + this.statutoryFirstBase = ''; + this.statutorySecondBase = ''; + } + + private asDate(year: number, month: number, day: number): string { + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + } + /** + * Form 1 saves the statutory form, because that is the only Form 1 this screen shows. + * + * Saved under its own form code rather than FORM_1: a saved return is rendered from its stored + * snapshot, and a statutory statement filed under the working report's code would be opened by + * the reader built for the other shape. + */ + protected override saveableReturn(): { formCode: string; reportingDate: string; report: any } | null { + return this.statutory + ? { formCode: this.statutory.formCode, reportingDate: this.statutory.reportingDate, report: this.statutory } + : null; + } +} diff --git a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss new file mode 100644 index 000000000..938ff68c2 --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss @@ -0,0 +1,509 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +$primary: #1353a0; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; +$green: #137333; +$amber: #b06000; +$red: #c5221f; + +.f1s-container { + max-width: 1180px; + margin: 0 auto; + padding: 20px 16px; +} + +.f1s-loading { + padding: 36px; + text-align: center; + color: $gray-500; + font-size: 13px; +} + +.f1s-alert { + padding: 12px 16px; + border-radius: 8px; + font-size: 12.5px; + margin-bottom: 16px; + line-height: 1.6; + + &--error { + background: #fce8e6; + color: $red; + } + + &--warn { + background: #fef7e0; + color: $amber; + } +} + +.f1s-unmet { + margin: 8px 0 0 18px; + padding: 0; +} + +// ── Date prompt ── +// Shown before anything is fetched. Centred and roomy rather than a strip at the top, because at +// this point choosing the date is the whole of the screen's job. + +.f1s-prompt { + max-width: 640px; + margin: 48px auto; + background: #fff; + border: 1px solid $border; + border-radius: 12px; + padding: 32px; + text-align: center; + + &__title { + margin: 0 0 6px; + font-size: 18px; + font-weight: 700; + color: $gray-900; + } + + &__sub { + margin: 0 0 24px; + font-size: 12.5px; + color: $gray-500; + line-height: 1.7; + } + + // The date pairs. Each row holds two, and the rows stack — so the closes sit directly above the + // bases they produce, and a filer reads down a column rather than across a wrapped line. + &__row { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + + & + & { + margin-top: 10px; + } + + // The bases are filled from the closes above rather than typed, so they are set back a shade - + // enough to read as "these follow from those" without looking disabled, since they can be + // changed and sometimes must be. + &--derived { + opacity: 0.85; + } + } + + &__date { + width: 220px; + } + + // The button on its own line under the pairs, centred on them rather than trailing the last one. + &__go { + margin-top: 16px; + display: flex; + justify-content: center; + } + + &__hint { + margin: 14px 0 0; + font-size: 12.5px; + color: $primary; + } + + &__quick { + margin-top: 18px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 12px; + color: $gray-500; + + button { + font-size: 12px !important; + min-width: 0 !important; + } + } +} + +// The strip above the sheet when the statutory form is shown as a tab: says what period is on +// screen and offers the way back to the date prompt. +.f1s-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 14px; + + &__date { + font-size: 12.5px; + font-weight: 600; + color: $primary; + } +} + +// ── Toolbar ── + +.f1s-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 16px; + + &__title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: $gray-900; + } + + &__ref { + margin: 2px 0 0; + font-size: 12px; + color: $gray-500; + } + + &__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + + i { + margin-right: 6px; + } + } +} + +.f1s-date { + width: 180px; +} + +// ── The sheet ── +// Bordered and squared off rather than card-like: this is a statutory return that gets printed and +// signed, and it should read on screen as the same document that comes out of the printer. + +.f1s-sheet { + background: #fff; + border: 1px solid $gray-900; + padding: 20px; + + &__head { + text-align: center; + border-bottom: 2px solid $gray-900; + padding-bottom: 12px; + margin-bottom: 16px; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: $gray-900; + text-transform: uppercase; + } + + &__ref { + margin-top: 4px; + font-size: 12px; + color: $gray-500; + } +} + +.f1s-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th, + td { + border: 1px solid $gray-200; + padding: 6px 10px; + vertical-align: top; + } + + th { + background: $gray-50; + font-weight: 700; + font-size: 11.5px; + color: $gray-900; + text-align: center; + border-color: $gray-900; + } + + &__particulars { + text-align: left !important; + width: 52%; + } + + &__num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + // A column heading is a date, not a figure, and sits centred over the column it heads. Qualified + // with `th` so it beats `&__num` above on specificity rather than on source order. + th#{&}__num { + text-align: center; + } + + // The second line of a heading — what the column IS, under the date it carries. Without it the + // three dates read as three closes, and the base the ratios are charged on looks like one more + // reporting date. + &__colnote { + display: block; + margin-top: 2px; + font-weight: 400; + font-size: 9.5px; + letter-spacing: 0.02em; + text-transform: uppercase; + color: $gray-500; + } + + // The date the column's ratios are charged on. In the heading rather than in a column of its own, + // so a reader sees which base produced the required amounts below without the base's balances + // sitting beside the closes' as though they were a third period. + &__colbase { + display: block; + margin-top: 1px; + font-weight: 400; + font-size: 9px; + letter-spacing: 0.02em; + color: $primary; + } +} + +// Part banner — PART A / PART B / PART C. +.f1s-part td { + background: $primary; + color: #fff; + font-weight: 700; + font-size: 12px; + letter-spacing: 0.03em; + border-color: $primary; +} + +// Section caption inside a part, e.g. "(a) Demand Liabilities". +.f1s-heading td { + background: $gray-50; + font-weight: 700; +} + +.f1s-total td { + font-weight: 700; + background: #e8f0fe; +} + +.f1s-total--result td { + background: #e6f4ea; +} + +// A shortfall reads as one rather than as a number with a minus sign. +.f1s-neg { + color: $red; +} + +.f1s-subnote td { + font-size: 11px; + font-style: italic; + color: $gray-500; + background: #fffdf5; +} + +// The form nests to three levels — (a), then (i), then the figure. +.f1s-indent-1 { + padding-left: 26px !important; +} + +.f1s-indent-2 { + padding-left: 46px !important; +} + +.f1s-badge { + margin-left: 8px; + font-size: 10px; + font-weight: 600; + background: #fef7e0; + color: $amber; + border-radius: 20px; + padding: 1px 8px; + white-space: nowrap; +} + +// ── Day by day ── +// Outside the form's own sheet, because it is not part of what gets filed: the form reports two +// closes, and this is the working behind them. +.f1s-daily { + margin-top: 24px; + background: #fff; + border: 1px solid $border; + border-radius: 10px; + padding: 18px 20px; + + &__title { + margin: 0 0 4px; + font-size: 14px; + font-weight: 700; + color: $gray-900; + } + + // A day short is a breach, and on thirty rows it is exactly what the eye skips. Said in the + // heading so it is read before the table rather than found in it. + &__flag { + display: inline-block; + margin-left: 10px; + padding: 2px 8px; + font-size: 10.5px; + font-weight: 700; + color: $red; + background: #fce8e6; + border-radius: 4px; + } + + &__hint { + margin: 0 0 14px; + font-size: 11.5px; + color: $gray-500; + } + + &__stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 16px; + } + + // The base a day was charged on. Set back because it repeats for every day of a fortnight and + // would otherwise compete with the figures it qualifies. + &__base { + color: $gray-500; + font-size: 11px; + } + + // A day the bank's own liabilities moved. The whole point of carrying a daily figure is to find + // these, and on thirty rows of near-identical numbers they are invisible unless marked. + &__moved { + font-weight: 700; + color: $primary; + background: #f0f6ff; + } + + &__table { + font-size: 11.5px; + + th { + font-size: 10.5px; + } + } + + &--short td { + background: #fff6f6; + } +} + +.f1s-stat { + border: 1px solid $border; + border-radius: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 2px; + + &__label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $gray-500; + font-weight: 600; + } + + &__value { + font-size: 17px; + font-weight: 600; + color: $gray-900; + font-variant-numeric: tabular-nums; + } + + &__sub { + font-size: 10.5px; + color: $gray-500; + } +} + +.f1s-sign { + margin-top: 56px; + text-align: right; + + &__line { + display: inline-block; + border-top: 1px solid $gray-900; + padding-top: 6px; + font-size: 12px; + font-weight: 600; + min-width: 220px; + text-align: center; + } +} + +.f1s-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: $gray-900; + color: #fff; + font-size: 12.5px; + padding: 10px 18px; + border-radius: 8px; + z-index: 1000; +} + +// ── Print ── +// The browser's print dialog is also its save-as-PDF, so one stylesheet serves both. The sheet is +// already laid out as the filed form, so printing only has to drop the controls and keep the +// shading that carries meaning. +@media print { + .f1s-noprint { + display: none !important; + } + + .f1s-container { + max-width: none; + padding: 0; + } + + .f1s-sheet { + border: none; + padding: 0; + } + + .f1s-table thead { + display: table-header-group; + } + + .f1s-table tr { + break-inside: avoid; + } + + .f1s-toast { + display: none !important; + } + + .f1s-container, + .f1s-sheet, + .f1s-table, + th, + td { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } +} diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts new file mode 100644 index 000000000..cf3c439e1 --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts @@ -0,0 +1,2133 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { inject } from '@angular/core'; +import { Observable, catchError, forkJoin, of } from 'rxjs'; +import * as ExcelJS from 'exceljs'; +import { RbiCircularsService } from '../rbi-circulars.service'; +import { addBanner, boxAllCells, downloadWorkbook, hatchFill, newWorkbook, solidFill, XL } from '../oss-workbook'; + +/** One configured figure on the return, with the accounts it was derived from. */ +export interface ReserveLine { + label: string; + configKey: string; + configured: boolean; + asOnDate: string; + amount: number | null; + /** + * The same head read at both of the return's dates, so a row can be compared across them. The + * return is built from figures struck at two different dates — the base at its lagged date, the + * holdings at the reporting date — and without both a reader cannot tell a real movement from the + * two dates simply being different. + */ + baseAmount?: number | null; + currentAmount?: number | null; + movement?: number | null; + /** + * The accounts the figure was derived from. `balance` is null and `excluded` set when an account + * is still pinned to the role but no longer countable — disabled, or turned into a header whose + * balance would double-count its children. + */ + accounts: ReserveLineAccount[]; +} + +/** + * One account behind a line. `accountBalance` is what the ledger holds; `balance` is the portion + * this head takes of it. The two differ whenever `weightPercentage` is under 100 — RBI treats part + * of a savings balance as a demand liability and the rest as time, so the same account can appear + * twice under one head with a different share each time. + */ +export interface ReserveLineAccount { + glAccountId: number; + glCode: string; + name: string; + /** The account's own balance, before the head's share is applied. */ + accountBalance?: number | null; + /** The share of that balance this head takes. 100 means the whole of it. */ + weightPercentage?: number | null; + /** DEMAND or TIME on a liability head; null elsewhere. */ + liabilityClass?: string | null; + balance: number | null; + excluded?: 'disabled' | 'header'; + /** The mapping row this came from — one account can be mapped to a head more than once. */ + mappingId?: number; + /** + * The same account read at each of the return's two dates, so the breakdown explains both + * columns rather than only the one its line is struck at. Null where the account was not mapped + * at the other date — it did not hold nothing, it was not on the return. + */ + baseBalance?: number | null; + currentBalance?: number | null; + baseAccountBalance?: number | null; + currentAccountBalance?: number | null; + movement?: number | null; +} + +// ── The statutory Form 1 ── +// +// Shapes for the return as a co-operative bank files it: three dated columns, Part A establishing +// the liabilities and Parts B and C settling the two ratios on that same base. It is shown as a tab +// on the Form 1 screen rather than as a page of its own. + +/** One printed row: a heading, a figure line, or a total. */ +export interface Form1Row { + label: string; + configKey?: string | null; + indent?: number; + heading?: boolean; + total?: boolean; + configured?: boolean; + values?: number[]; + /** On the base-date row, the date each column's liabilities were read at. */ + baseDates?: string[]; +} + +export interface Form1Part { + title: string; + lines: Form1Row[]; + required?: number[]; + maintained?: number[]; + surplus?: number[]; + baseDates?: string[]; + baseDateLiabilities?: number[]; +} + +/** One day of the period, with both ratios measured against the base in force that day. */ +export interface Form1DailyRow { + date: string; + /** The date this day's ratios were charged on — it moves at each fortnight boundary. */ + baseDate: string; + /** + * The net liabilities that date held, which both requirements below are struck on. + * + * Absent on a return saved before the backend stated it, so readers treat it as optional rather + * than printing a zero where an older snapshot simply never carried the figure. + */ + baseLiabilities?: number; + /** + * The day's own net liabilities, which move daily where the base holds still for a fortnight. + * + * Optional for the same reason as the base: a return saved before the backend stated it carries + * no figure, and a zero would read as a day with no liabilities rather than as one not recorded. + */ + ownLiabilities?: number; + crrRequired: number; + crrMaintained: number; + /** Negative where the day fell short. */ + crrSurplus: number; + slrRequired: number; + slrMaintained: number; + slrSurplus: number; +} + +/** + * The day-by-day position behind the form's two columns. + * + * Both ratios are reckoned over the period, not on the closes alone, so a form whose columns pass + * can still sit on days that did not. The short-day counts say so outright rather than leaving them + * to be spotted in a table of thirty rows. + */ +export interface Form1Daily { + from: string; + to: string; + days: Form1DailyRow[]; + dayCount: number; + crrAverage: number; + slrAverage: number; + crrLowest: number; + crrLowestOn: string | null; + slrLowest: number; + slrLowestOn: string | null; + crrShortDays: number; + slrShortDays: number; +} + +export interface Form1Statutory { + formCode: string; + formName: string; + statutoryReference: string; + reportingDate: string; + /** The fortnight closes the form reports, in time order. */ + columns: string[]; + /** + * The date each column's ratios are charged on, in the same order. + * + * Absent on a return saved before the base stopped being a column of its own, so the screen reads + * it defensively rather than assuming every snapshot carries it. + */ + baseColumns?: string[]; + /** Every day of the period, with what each ratio required and what was held. */ + daily?: Form1Daily; + crrPercentage: number | null; + slrPercentage: number | null; + partA: Form1Part; + /** + * Part B settles section 18 — the cash reserve. Absent on a return that settles only section 24, + * where printing it would put a CRR compliance statement on an SLR form. + */ + partB?: Form1Part; + partC: Form1Part; + unmetRequirements: string[]; +} + +/** One row of the Memorandum or an Annexure — a stated figure, entering no total. */ +export interface SectionRow { + label: string; + configKey: string; + configured: boolean; + amount: number | null; + accounts: { glAccountId: number; glCode: string; name: string; balance: number }[]; +} + +/** + * Compliance across the whole fortnight. + * + * CRR is not met by the closing day's balance: the full requirement must be held on average over the + * fortnight AND a daily minimum on every single day of it, so a bank that ran empty for thirteen days + * and topped up on the last one fails on the daily test while passing on the closing position. + */ +export interface FortnightMaintenance { + /** Each day of the month, with the requirement it was measured against and the gap. */ + days: { date: string; required: number; amount: number; surplus: number }[]; + /** The month the table covers. */ + periodStart?: string; + periodEnd?: string; + periodLabel?: string; + dayCount: number; + average: number; + lowest: number; + lowestOn: string | null; + /** False while the fortnight is still running, so the average is of the days elapsed. */ + fortnightComplete: boolean; + requiredAverage: number; + dailyMinimumPercent: number; + dailyMinimumAmount: number; + averageMet: boolean; + dailyMinimumMet: boolean; + averageShortfall: number; +} + +/** The whole return, as the backend computes it. */ +export interface ReserveReturn { + formCode: string; + formName: string; + frequency: string; + reportingDate: string; + rateType: string; + ratePercentage: number | null; + /** True while the rate is still the placeholder shipped with the installation. */ + rateIsSeedDefault: boolean; + basis: { + lagDays?: number; + rawLagDate?: string; + /** The nominal reporting Friday, before any shift off a holiday. */ + reportingFriday?: string; + dtlAsOnDate: string; + holdingsAsOnDate: string; + /** True when that Friday was a holiday and the position was taken a working day earlier. */ + shiftedForHoliday?: boolean; + note: string; + }; + lines: ReserveLine[]; + /** The two dates the return is struck at, so the table can head its columns with them. */ + baseDate?: string; + currentDate?: string; + /** Form A's Memorandum and its two Annexures — sections of the return, not lines on it. */ + memorandum?: SectionRow[]; + annexureA?: SectionRow[]; + annexureB?: SectionRow[]; + /** Compliance across the fortnight rather than on the reporting date alone. Form 1 only. */ + maintenance?: FortnightMaintenance; + computable: boolean; + dtlAmount?: number; + requiredAmount?: number; + maintainedAmount?: number; + surplusOrShortfall?: number; + maintainedPercentage?: number | null; + compliant?: boolean; + unmetRequirements: string[]; + // Form 1 only. + fortnight?: number; + fortnightLabel?: string; + fortnightStart?: string; + fortnightEnd?: string; + // Form 9 only. + requestedDate?: string; + monthStart?: string; +} + +/** + * Shared behaviour for the two reserve returns — Form 1 (CRR) and Form 9 (SLR). + * + * Both are the same statement: a percentage of Demand and Time Liabilities must be maintained in a + * defined class of assets, and the return compares what is required against what is held. They + * differ only in which figures make up the maintained side and on which dates each side is read, + * so everything except the fetch and the Excel layout lives here. + * + * Nothing is computed in the browser. The figures come from the ledger through one endpoint that + * already aggregates them; re-deriving totals here would produce a second set of numbers that could + * disagree with the filed return. + */ +export abstract class ReserveReturnBase { + protected rbiService = inject(RbiCircularsService); + + /** Which return to fetch. */ + protected abstract fetch(reportingDate: string): Observable; + + /** Filename stem for the Excel export. */ + protected abstract readonly exportStem: string; + + /** Which return this screen is, so saved returns list only this one's. */ + protected abstract readonly formCode: string; + + data: ReserveReturn | null = null; + loading = true; + errorMsg = ''; + generating = false; + + /** The reporting date the user is looking at. Editable, so past periods can be reviewed. */ + reportingDate = new Date().toISOString().slice(0, 10); + + /** + * The date the liability base is struck at, given outright. + * + * The 45-day lag lands where it lands, and a bank reading an earlier fortnight — or correcting a + * filing — needs to name the date rather than work backwards from it. Left blank it is derived + * from the lag as before, and the return says on its face which of the two it used. + * + * Form 9 ignores it: there both sides are struck at the month end, so there is no second date to + * choose. + */ + baseDate = ''; + + /** Whether this return has a base date distinct from its reporting date. Form 9 does not. */ + readonly hasBaseDate: boolean = false; + + /** + * Nothing is fetched until a date is chosen. + * + * The return is dated: which fortnight it covers, which lagged date the base is read at and which + * rate is in force all follow from it. Opening straight onto today's figures invites a filer to + * read a return for a period they never asked for, so the screen asks first. + */ + awaitingDate = true; + + /** + * What the prompt calls this return, before any data has been fetched to name it. Once the return + * runs its own `formName` is used instead. + */ + get promptTitle(): string { + return this.formCode === 'FORM_9' ? 'Form 9 — Statutory Liquidity Ratio' : 'Form 1 — Cash Reserve Ratio'; + } + + /** Runs the return for the chosen date. */ + generate(): void { + if (!this.reportingDate) { + this.errorMsg = 'Choose a reporting date first.'; + return; + } + // `max` on the input stops the picker, not a typed or pasted date, and a future one returns + // today's figures under a heading that has not happened rather than failing. + if (this.reportingDate > this.today) { + this.errorMsg = `The reporting date (${this.reportingDate}) has not happened yet — today is ${this.today}.`; + return; + } + if (this.baseDate && this.baseDate > this.today) { + this.errorMsg = `The base date (${this.baseDate}) has not happened yet — today is ${this.today}.`; + return; + } + this.awaitingDate = false; + this.load(); + } + + /** + * Back to the date prompt, so a filer can run a different period. Both dates are kept as entered: + * moving from one fortnight to the next usually means editing one field, not retyping both. + */ + changeDate(): void { + this.awaitingDate = true; + this.data = null; + this.errorMsg = ''; + } + + /** + * The fortnight the chosen date falls in, shown beside the picker so a filer sees which period + * they are about to run. Fortnights close on the 15th and on the month end. + */ + get chosenFortnight(): string { + if (!this.reportingDate) { + return ''; + } + const d = new Date(this.reportingDate + 'T00:00:00'); + const first = d.getDate() <= 15; + const end = first ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + const month = d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' }); + return `${first ? 1 : 16}–${end} ${month}`; + } + + /** Jumps the picker to a fortnight close, which is what a filed return is usually dated. */ + setToFortnightClose(which: 'first' | 'second'): void { + const d = this.reportingDate ? new Date(this.reportingDate + 'T00:00:00') : new Date(); + const day = which === 'first' ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + this.reportingDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + } + + toastMsg = ''; + toastVisible = false; + private toastTimer: any; + + /** Returns saved earlier, listed under the live one. */ + savedReturns: any[] = []; + savedLoading = false; + saving = false; + savedRemarks = ''; + + /** + * Which half of the screen is showing: the return itself, or the ones kept on record. + * + * Two tabs rather than one long page: the live return and the saved list are read at different + * times and for different reasons, and stacking them meant scrolling past the whole statement - + * including a month of daily rows - to reach the list at all. + */ + activeTab: 'report' | 'statutory' | 'saved' = 'report'; + + showTab(tab: 'report' | 'statutory' | 'saved'): void { + this.activeTab = tab; + } + + /** + * Whether this screen also offers the statutory Form 1 — the return as a co-operative bank files + * it, three dated columns with both ratios settled on one base. Only Form 1 does; Form 9 has no + * statutory counterpart in these rules, so its subclass leaves this false. + */ + readonly hasStatutoryForm: boolean = false; + + /** + * Whether the statutory form asks for two fortnight closes or one reporting date. + * + * Form 1 is filed fortnightly and reports two closes side by side, so a filer names both. Form 9 + * is filed monthly: the month it covers settles which columns it prints, and asking for two dates + * there would let a filer name a pair that is not a month. + */ + readonly statutoryTakesTwoColumns: boolean = true; + + /** What the statutory form is called on its tab, so the two returns cannot be confused. */ + readonly statutoryTabLabel: string = 'Form 1'; + + /** What the statutory form is called on its own prompt and button. */ + readonly statutoryTitle: string = 'Form 1 — Sections 18(1) and 24(3)'; + readonly statutorySubtitle: string = + 'Banking Regulation (Co-operative Societies) Rules, 1966. The form reports two fortnight closes ' + + 'side by side — for September that is 15/09 and 30/09 — and charges both ratios on the matching ' + + 'close one month earlier.'; + + // ── Statutory form state ── + // + // Held here rather than on Form 1 because Form 9 now carries a statutory form too, and the + // template they share reads these directly. + + /** The statutory return, fetched only when its tab is first opened. */ + statutory: Form1Statutory | null = null; + statutoryLoading = false; + statutoryError = ''; + + /** + * Nothing is fetched for the statutory form until a date is chosen. Which period it covers, which + * columns it prints and which base date the ratios are charged on all follow from that one field. + */ + statutoryAwaitingDate = true; + statutoryDate = new Date().toISOString().slice(0, 10); + + /** The two fortnight closes the form's first two columns report. Unused on a monthly return. */ + statutoryFirstColumn = ''; + statutorySecondColumn = ''; + + /** + * The base date each column's ratios are charged on. + * + * Filled in from the rule as soon as a close is entered — the last Friday of that close's second + * preceding fortnight — and editable from there. Shown rather than hidden because every required + * amount on the form is a percentage of the liabilities on this date, and a reviewer cannot check + * a requirement whose base they cannot see. Editable because a bank that migrated part-way + * through a period has a base the rule produces and the ledger cannot answer. + */ + statutoryFirstBase = ''; + statutorySecondBase = ''; + + /** + * The base date a close is charged on: the previous month's matching fortnight close. + * + * Which fortnight the column falls in settles it, not the day it carries. A date in the first + * half — the 1st to the 15th — charges on the 15th of the previous month; anything later charges + * on the previous month's END, whatever day that is. So 30/09 charges on 31/08, and so does + * 20/09: both sit in the second fortnight, and the second fortnight's close is the month end. + * + * Keyed on the fortnight rather than on the day number because the two closes are not the same + * kind of date. The 15th is a fixed day; the month end moves between 28 and 31. Matching day for + * day would charge 30/09 on 30/08, a day that closes nothing. + * + * RBI's own wording is "the last Friday of the second preceding fortnight", and that rule was + * tried here. It was taken out again because the dates it produced did not match the returns this + * bank has already filed by hand — it puts the base on a weekday that moves and roughly six weeks + * back. The fortnight closes this bank actually files on are the 15th and the month end. + * + * Mirrors the backend, which computes the same date for the figures. Kept here so the prompt can + * fill the base in as the close is typed; a base entered by hand still overrides it. + */ + baseDateFor(closeDate: string): string { + if (!closeDate) { + return ''; + } + const d = new Date(closeDate + 'T00:00:00'); + if (Number.isNaN(d.getTime())) { + return ''; + } + // A date input hands over whatever has been typed so far, and a half-typed year parses as a + // real date: "0026-09-30" is year 26, which the Date constructor then reads as 1926, because + // it adds 1900 to any year from 0 to 99. The base silently came back a century out. Nothing + // before 1900 is a date this form has any use for, so it is treated as still being typed. + if (d.getFullYear() < 1900) { + return ''; + } + // The previous month, reached by taking day 0 of this one: month is zero-based, so this lands + // on the last day of the month before. + const previousMonthEnd = new Date(d.getFullYear(), d.getMonth(), 0); + if (d.getDate() <= 15) { + return this.asIsoDate(new Date(previousMonthEnd.getFullYear(), previousMonthEnd.getMonth(), 15)); + } + return this.asIsoDate(previousMonthEnd); + } + + private asIsoDate(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + /** Fills a column's base from the rule, leaving one the filer has already set alone. */ + onStatutoryColumnChange(which: 'first' | 'second'): void { + if (which === 'first') { + this.statutoryFirstBase = this.baseDateFor(this.statutoryFirstColumn); + } else { + this.statutorySecondBase = this.baseDateFor(this.statutorySecondColumn); + } + } + + /** Fetches the statutory form. Overridden by each return that has one. */ + protected fetchStatutory(): Observable { + return of(null); + } + + /** Runs the statutory return for the dates entered. */ + generateStatutory(): void { + if (this.statutoryTakesTwoColumns) { + if (!this.statutoryFirstColumn || !this.statutorySecondColumn) { + this.statutoryError = 'Enter both fortnight closing dates — for September that is 15/09 and 30/09.'; + return; + } + // A half-typed year parses as a real date — "30-09-0026" is year 26 — and then fails the + // ordering check below with a message about column order, which is not what went wrong. + // Caught first so the filer is told about the year rather than sent looking at the dates. + const halfTyped = [ + this.statutoryFirstColumn, + this.statutorySecondColumn, + this.statutoryFirstBase, + this.statutorySecondBase + ].filter((d) => d && Number(d.slice(0, 4)) < 1900); + if (halfTyped.length) { + this.statutoryError = `The year in ${halfTyped[0]} looks incomplete — enter all four digits.`; + return; + } + if (this.statutoryFirstColumn >= this.statutorySecondColumn) { + // The form reads left to right as the period advances. Reversed, every movement on it would + // carry the wrong sign while still looking like a properly filled return. + this.statutoryError = 'The first column must fall before the second.'; + return; + } + if (this.statutorySecondColumn > this.today) { + this.statutoryError = `The second column (${this.statutorySecondColumn}) has not happened yet — today is ${this.today}.`; + return; + } + // A base dated after the close it serves would charge the ratio on liabilities the bank had + // not taken on yet — arithmetically fine, and meaningless. + if (this.statutoryFirstBase && this.statutoryFirstBase > this.statutoryFirstColumn) { + this.statutoryError = `The first column's base date (${this.statutoryFirstBase}) falls after the close it is charged on.`; + return; + } + if (this.statutorySecondBase && this.statutorySecondBase > this.statutorySecondColumn) { + this.statutoryError = `The second column's base date (${this.statutorySecondBase}) falls after the close it is charged on.`; + return; + } + // The return is made up to the later of the two closes, so the reporting date follows from + // them rather than being a third date a filer has to keep consistent with the first two. + this.statutoryDate = this.statutorySecondColumn; + } else { + if (!this.statutoryDate) { + this.statutoryError = 'Choose a reporting date first.'; + return; + } + if (this.statutoryDate > this.today) { + this.statutoryError = `The reporting date (${this.statutoryDate}) has not happened yet — today is ${this.today}.`; + return; + } + } + + this.statutoryAwaitingDate = false; + this.statutoryLoading = true; + this.statutoryError = ''; + this.fetchStatutory() + .pipe( + catchError((err: any) => { + this.statutoryError = + err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute the statutory return from the ledger.'; + return of(null); + }) + ) + .subscribe((res: Form1Statutory | null) => { + this.statutoryLoading = false; + this.statutory = res; + }); + } + + /** Back to the date prompt, so a filer can run a different period. */ + changeStatutoryDate(): void { + this.statutoryAwaitingDate = true; + this.statutory = null; + this.statutoryError = ''; + } + + /** All three parts in printing order, so the template walks one list. */ + get statutoryParts(): Form1Part[] { + if (!this.statutory) { + return []; + } + // partB is absent on a return that settles only section 24, so it is skipped rather than + // printed empty. + return [ + this.statutory.partA, + this.statutory.partB, + this.statutory.partC + ].filter((p): p is Form1Part => !!p); + } + + /** A shortfall reads as one rather than as a number with a minus sign. */ + isShort(value: number): boolean { + return value < 0; + } + + /** The saved return currently open below the list, or null when none is. */ + viewingSaved: any = null; + /** + * The opened snapshot's own statement, rendered in its own section. Held separately from `data` so + * the live return stays on screen alongside it and the two can be compared directly. + */ + savedData: ReserveReturn | null = null; + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.fetch(this.reportingDate) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute this return from the ledger.'; + return of(null); + }) + ) + .subscribe((res: ReserveReturn | null) => { + this.loading = false; + this.data = res; + }); + } + + onDateChange(): void { + this.load(); + } + + /** Jump to the period containing today. */ + resetToToday(): void { + this.reportingDate = new Date().toISOString().slice(0, 10); + this.load(); + } + + // ── presentation helpers ── + + /** + * The titled sections Form A carries alongside the main statement, in the order the format states + * them. Each is skipped by the template when it has no rows, so Form 9 — which has none — is + * unaffected. + */ + get namedSections(): { title: string; hint: string; rows: SectionRow[] }[] { + if (!this.data) { + return []; + } + return [ + { + title: 'Memorandum to Form A', + hint: 'Stated alongside the return. These figures are reported, not added into the CRR requirement.', + rows: this.data.memorandum ?? [] + }, + { + title: 'Annexure A — Foreign currency liabilities and assets', + hint: 'Reported in INR equivalent at the applicable reference rate. Nil where the bank has no foreign-currency business.', + rows: this.data.annexureA ?? [] + }, + { + title: 'Annexure B — Investment details', + hint: 'Investments split between approved and non-approved securities, shares, debentures and bonds.', + rows: this.data.annexureB ?? [] + } + ]; + } + + get isShortfall(): boolean { + return !!this.data?.computable && this.data.compliant === false; + } + + /** Magnitude of the gap, so the sign lives in the label rather than in the number. */ + get shortfallAmount(): number { + return Math.abs(this.data?.surplusOrShortfall ?? 0); + } + + // ── Saved returns ── + + loadSaved(): void { + this.savedLoading = true; + // Returns saved under a code this screen no longer writes are still returns this screen filed. + // Form 1 moved from the working report's code to the statutory one, and fetching only the + // current code would hide every return saved before that — which is exactly the history a + // saved list exists to hold. + const codes = [ + this.formCode, + ...this.legacyFormCodes + ]; + forkJoin(codes.map((code) => this.rbiService.getSavedReturns(code).pipe(catchError(() => of([]))))).subscribe( + (results: any[][]) => { + this.savedLoading = false; + this.savedReturns = results + .flat() + .filter((row) => row) + .sort((a, b) => String(b.reportingDate ?? '').localeCompare(String(a.reportingDate ?? ''))); + } + ); + } + + /** + * Codes this screen used to save under. Listed alongside the current one so nothing already + * filed drops out of the list when a form code changes. + */ + protected readonly legacyFormCodes: string[] = []; + + /** Keeps the return on screen, so it can be read again later exactly as it stands now. */ + saveCurrent(): void { + // What is on screen is what gets saved. On a return whose statutory form IS the return, the + // working report is never fetched, so saving `data` would save nothing at all — and the return + // the filer was looking at could not be kept. + const snapshot = this.saveableReturn(); + if (!snapshot) { + return; + } + this.saving = true; + this.rbiService + .saveReturn(snapshot.formCode, snapshot.reportingDate, snapshot.report, this.savedRemarks || undefined) + .pipe( + catchError((err: any) => { + this.saving = false; + this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this return.'); + return of(null); + }) + ) + .subscribe((rows: any[] | null) => { + this.saving = false; + if (!rows) { + return; + } + this.savedReturns = rows; + this.savedRemarks = ''; + this.toast('Return saved.'); + }); + } + + /** + * Opens a saved return in its own section below the list, leaving the live return on screen. + * + * Deliberately not in place of the live one: the reason to open a saved return is almost always to + * compare it against where the bank stands now, and swapping the live figures out makes that + * comparison impossible without clicking back and forth from memory. + */ + viewSaved(row: any): void { + // Clicking the open one closes it, so the button toggles rather than re-fetching. + if (this.viewingSaved?.id === row.id) { + this.closeSaved(); + return; + } + this.rbiService + .getSavedReturn(row.id) + .pipe( + catchError(() => { + this.toast('Could not open this saved return.'); + return of(null); + }) + ) + .subscribe((saved: any) => { + if (!saved?.report) { + this.toast('This saved return has no stored statement.'); + return; + } + this.viewingSaved = saved; + this.savedData = saved.report as ReserveReturn; + // Opens on the Saved tab, where the list it was chosen from is - never jumping the user to + // the live return, which is a different statement. + this.activeTab = 'saved'; + setTimeout(() => document.getElementById('rr-saved-view')?.scrollIntoView({ behavior: 'smooth' }), 0); + }); + } + + /** Closes the opened snapshot. The live return was never hidden, so nothing is restored. */ + closeSaved(): void { + this.viewingSaved = null; + this.savedData = null; + } + + /** Kept for callers that still name the old action. */ + backToLive(): void { + this.closeSaved(); + } + + /** Magnitude of the opened snapshot's gap, so the sign lives in its label rather than the number. */ + get savedSurplus(): number { + return Math.abs(this.savedData?.surplusOrShortfall ?? 0); + } + + updateSavedStatus(row: any, status: string): void { + this.rbiService + .updateSavedReturnStatus(row.id, status) + .pipe( + catchError((err: any) => { + this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not update the status.'); + return of(null); + }) + ) + .subscribe((rows: any[] | null) => { + if (!rows) { + return; + } + this.savedReturns = rows; + if (this.viewingSaved?.id === row.id) { + this.viewingSaved = { ...this.viewingSaved, status }; + } + this.toast(`Marked ${this.statusLabel(status)}.`); + }); + } + + deleteSaved(row: any): void { + this.rbiService + .deleteSavedReturn(row.id) + .pipe( + catchError(() => { + this.toast('Could not remove this saved return.'); + return of(null); + }) + ) + .subscribe((rows: any[] | null) => { + if (!rows) { + return; + } + this.savedReturns = rows; + if (this.viewingSaved?.id === row.id) { + this.backToLive(); + } + this.toast('Saved return removed.'); + }); + } + + /** The stages a return moves through, in order, for the status menu. */ + readonly filingStatuses = [ + 'DRAFT', + 'GENERATED', + 'CHECKED', + 'APPROVED', + 'PROVISIONAL_SUBMITTED', + 'FINAL_SUBMITTED', + 'REVISED' + ]; + + statusLabel(status: string): string { + const labels: Record = { + DRAFT: 'Draft', + GENERATED: 'Generated', + CHECKED: 'Checked', + APPROVED: 'Approved', + PROVISIONAL_SUBMITTED: 'Provisional submitted', + FINAL_SUBMITTED: 'Final submitted', + REVISED: 'Revised' + }; + return labels[status] ?? status; + } + + /** + * The return currently on screen, in the shape the saved list stores. + * + * Overridden where the screen shows something other than the working report, so that Save keeps + * the statement the filer was actually reading rather than one they never saw. + */ + protected saveableReturn(): { formCode: string; reportingDate: string; report: any } | null { + return this.data + ? { formCode: this.data.formCode, reportingDate: this.data.reportingDate, report: this.data } + : null; + } + + /** Whether there is anything on screen to save — drives the Save button. */ + get canSave(): boolean { + return this.saveableReturn() !== null; + } + + /** + * Today, as a date input formats it — the latest date any of these returns may be run for. + * + * A balance is summed as everything up to a date, so a future one does not fail and does not come + * back empty: it returns today's figures under a heading that has not happened, and a period + * still running reads as one that has closed. + */ + readonly today = new Date().toISOString().slice(0, 10); + + /** + * Whether the opened snapshot is a statutory form rather than a working report. + * + * The two are stored in the same list but are different statements: the working report carries a + * `basis` block and one amount per line, the statutory form carries three dated columns and no + * basis at all. Rendering one through the other's markup reads every missing field as blank, so + * the shape is checked before anything is drawn. + */ + get savedIsStatutory(): boolean { + return (this.savedData as any)?.formCode === 'FORM_1_STATUTORY'; + } + + /** The opened snapshot as a statutory form, for the template to walk its parts. */ + get savedStatutory(): Form1Statutory | null { + return this.savedIsStatutory ? (this.savedData as any as Form1Statutory) : null; + } + + /** The opened statutory snapshot's parts, in printing order. */ + get savedStatutoryParts(): Form1Part[] { + const s = this.savedStatutory; + // partB is absent on a snapshot of a return that settles only section 24, so it is skipped + // rather than rendered as an empty part with a heading and no lines. + return s ? [ + s.partA, + s.partB, + s.partC + ].filter((p): p is Form1Part => !!p) : []; + } + + /** + * True when a head takes only part of an account, so the printed figure is not that account's + * balance. Anything at or above 100, or unset, is the whole balance and needs no explanation. + */ + isPartial(account: ReserveLineAccount): boolean { + const weight = account.weightPercentage; + return weight !== null && weight !== undefined && weight < 100 && account.accountBalance != null; + } + + /** + * The balance a weighted account's share is taken of, stated for both of the return's dates + * where they differ. + * + * A weight applies to a different balance in each column, and printing only one of them makes + * the other column's figure look as though it does not follow from the share stated beside it. + * Where the account held the same balance on both dates — or is only on the return at one of + * them — one figure says everything there is to say. + */ + portionOf(account: ReserveLineAccount): string { + const base = account.baseAccountBalance; + const current = account.currentAccountBalance; + if (base === undefined && current === undefined) { + return this.amount(account.accountBalance); + } + if (base == null || current == null) { + return this.amount(base ?? current ?? account.accountBalance); + } + if (base === current) { + return this.amount(base); + } + return `${this.amount(base)} / ${this.amount(current)}`; + } + + amount(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + percent(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return `${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}%`; + } + + protected toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + } + + // ── Excel export ── + + /** + * Exports the return as filed: the same figures the screen shows, plus the basis dates and the + * account-level breakdown, so a reviewer can trace any total back to the accounts behind it. + */ + async exportWorkbook(): Promise { + if (!this.data) { + return; + } + this.generating = true; + try { + const wb = newWorkbook(); + const sheet = wb.addWorksheet(this.data.formCode.replace('_', ' ')); + // Five columns because the daily table is the widest block on the sheet; the statement above + // it uses the first three and leaves the rest empty. + sheet.columns = [ + { width: 52 }, + { width: 22 }, + { width: 20 }, + { width: 22 }, + { width: 16 }]; + + addBanner(sheet, `${this.data.formName} — ${this.data.frequency}`, 'C'); + + let start = sheet.rowCount + 1; + this.addKeyValue(sheet, 'Reporting date', this.data.reportingDate); + if (this.data.fortnightLabel) { + this.addKeyValue( + sheet, + 'Fortnight', + `${this.data.fortnightLabel} (${this.data.fortnightStart} to ${this.data.fortnightEnd})` + ); + } + this.addKeyValue(sheet, 'DTL base as at', this.data.basis.dtlAsOnDate); + this.addKeyValue(sheet, 'Balances maintained as at', this.data.basis.holdingsAsOnDate); + if (this.data.basis.rawLagDate) { + this.addKeyValue(sheet, `Lag (${this.data.basis.lagDays} days) falls on`, this.data.basis.rawLagDate); + } + this.addKeyValue( + sheet, + `${this.data.rateType} rate applied`, + this.data.ratePercentage === null + ? 'Not set' + : `${this.data.ratePercentage}%${this.data.rateIsSeedDefault ? ' (placeholder — not confirmed)' : ''}` + ); + boxAllCells(sheet, start, sheet.rowCount, 2); + sheet.addRow([]); + + // Basis note, spelled out so the export is self-explanatory away from the screen. + const noteRow = sheet.addRow([this.data.basis.note]); + sheet.mergeCells(noteRow.number, 1, noteRow.number, 3); + noteRow.alignment = { wrapText: true }; + noteRow.font = { size: 9, italic: true }; + sheet.addRow([]); + + // Figures. + start = sheet.rowCount + 1; + const head = sheet.addRow([ + 'Particulars', + 'As at', + 'Amount (INR)' + ]); + head.font = { bold: true }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + for (const line of this.data.lines) { + const row = sheet.addRow([ + line.label, + line.asOnDate, + line.configured ? line.amount : 'Not configured' + ]); + row.getCell(1).alignment = { wrapText: true }; + row.getCell(3).numFmt = '#,##0.00'; + row.getCell(3).alignment = { horizontal: 'right' }; + + // Account-level breakdown, indented under its line. An excluded account is named with its + // reason rather than dropped, so the workbook cannot read as complete when the figure is + // short by it — the file is what gets filed, so the caveat has to travel in it. + for (const account of line.accounts) { + // A partial share is named on the row too: without it the figure reads as the account's + // own balance and cannot be reconciled back to the ledger. + const portion = this.isPartial(account) + ? ` (${account.weightPercentage}% of ${this.amount(account.accountBalance)})` + : ''; + const sub = sheet.addRow([ + ` ${account.glCode} — ${account.name}${portion}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`, + account.liabilityClass ?? '', + account.excluded ? 'Excluded' : account.balance + ]); + sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } }; + sub.getCell(3).numFmt = '#,##0.00'; + sub.getCell(3).alignment = { horizontal: 'right' }; + } + } + + if (this.data.computable) { + const required = sheet.addRow([ + `${this.data.rateType} required (${this.data.ratePercentage}% of DTL)`, + '', + this.data.requiredAmount ?? null + ]); + const maintained = sheet.addRow([ + `${this.data.rateType} maintained`, + '', + this.data.maintainedAmount ?? null + ]); + const gap = sheet.addRow([ + this.data.compliant ? 'Surplus' : 'Shortfall', + '', + this.data.surplusOrShortfall ?? null + ]); + for (const row of [ + required, + maintained, + gap + ]) { + row.font = { bold: true }; + row.getCell(3).numFmt = '#,##0.00'; + row.getCell(3).alignment = { horizontal: 'right' }; + row.getCell(3).fill = solidFill(XL.derivedBlue); + } + } + boxAllCells(sheet, start, sheet.rowCount, 3); + + // Fortnight maintenance, which is what CRR compliance actually turns on. A workbook showing + // only the closing position would let a fortnight that breached the daily floor look clean. + if (this.data.maintenance) { + const m = this.data.maintenance; + sheet.addRow([]); + const head = sheet.addRow([ + 'Maintenance over the fortnight', + '', + '' + ]); + head.font = { bold: true }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + const start = sheet.rowCount + 1; + this.addKeyValue(sheet, 'Average maintained', this.amount(m.average)); + this.addKeyValue(sheet, 'Required average', this.amount(m.requiredAverage)); + this.addKeyValue(sheet, 'Average test', m.averageMet ? 'Met' : 'NOT MET'); + this.addKeyValue( + sheet, + `Daily floor (${m.dailyMinimumPercent}% of requirement)`, + this.amount(m.dailyMinimumAmount) + ); + this.addKeyValue(sheet, 'Lowest day', `${this.amount(m.lowest)} on ${m.lowestOn}`); + this.addKeyValue(sheet, 'Daily minimum test', m.dailyMinimumMet ? 'Met' : 'NOT MET'); + this.addKeyValue( + sheet, + 'Days counted', + `${m.dayCount}${m.fortnightComplete ? ' (full fortnight)' : ' (to reporting date)'}` + ); + boxAllCells(sheet, start, sheet.rowCount, 2); + + sheet.addRow([]); + const dayHead = sheet.addRow([ + 'Date', + 'Required (INR)', + 'Actual (INR)', + 'Surplus / (Shortfall)', + 'Against floor' + ]); + dayHead.font = { bold: true }; + dayHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + const dayStart = sheet.rowCount; + for (const day of m.days) { + const row = sheet.addRow([ + day.date, + day.required, + day.amount, + day.surplus, + day.amount < m.dailyMinimumAmount ? 'Below floor' : 'OK' + ]); + for (const col of [ + 2, + 3, + 4 + ]) { + row.getCell(col).numFmt = '#,##0.00'; + row.getCell(col).alignment = { horizontal: 'right' }; + } + } + boxAllCells(sheet, dayStart, sheet.rowCount, 5); + } + + // The Memorandum and Annexures, each under its own heading. + for (const section of this.namedSections) { + if (!section.rows.length) { + continue; + } + sheet.addRow([]); + const title = sheet.addRow([ + section.title, + '', + '' + ]); + title.font = { bold: true }; + title.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + const start = sheet.rowCount; + for (const row of section.rows) { + const excelRow = sheet.addRow([ + row.label, + '', + row.configured ? row.amount : 'Not configured' + ]); + excelRow.getCell(1).alignment = { wrapText: true }; + excelRow.getCell(3).numFmt = '#,##0.00'; + excelRow.getCell(3).alignment = { horizontal: 'right' }; + } + boxAllCells(sheet, start, sheet.rowCount, 3); + } + + // Caveats travel with the workbook, not only the screen: a return computed on the placeholder + // rate is arithmetically right and still must not be filed, and the file is what gets sent on. + if (this.data.unmetRequirements.length) { + sheet.addRow([]); + const prefix = this.data.computable ? 'Check before filing' : 'Not computed'; + for (const reason of this.data.unmetRequirements) { + const row = sheet.addRow([`${prefix}: ${reason}`]); + sheet.mergeCells(row.number, 1, row.number, 3); + row.font = { bold: true, size: 9 }; + row.alignment = { wrapText: true }; + } + } + + await downloadWorkbook(wb, `${this.exportStem}_${this.data.reportingDate}.xlsx`); + this.toast('Workbook downloaded.'); + } finally { + this.generating = false; + } + } + + // Protected rather than private: a subclass exporting its own form — the statutory Form 1 — needs + // the same key/value block, and a second copy of it would drift from this one. + protected addKeyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void { + const row = sheet.addRow([ + label, + value + ]); + row.getCell(1).font = { bold: true }; + row.getCell(1).fill = solidFill(XL.keyCell); + } + + /** + * Prints the form on its own, in a window carrying nothing else. + * + * `window.print()` on the page printed blank. The form sits inside the app shell — fixed nav + * bars, a scrolling content pane — and the print engine lays that out from the top of the + * document, so the sheet itself fell past the end of the first page with the shell's own chrome + * occupying it. Component styles could not fix it either: they are scoped to this component and + * cannot reach the shell that surrounds it. + * + * So the sheet is lifted into a window of its own with the styles it needs inlined, which is what + * the passbook screens do for the same reason. + */ + printReturn(): void { + const sheet = document.getElementById('f1s-printable'); + if (!sheet) { + // Nothing generated yet — printing an empty screen would produce the blank page this exists + // to prevent. + return; + } + + const win = window.open('', 'RbiStatutory', 'height=900,width=1200'); + if (!win) { + // Blocked by the browser. Said out loud rather than returning quietly: a Print button that + // does nothing reads as the blank page this replaced. + this.toast('Allow pop-ups for this site to print the form.'); + return; + } + const title = `${this.statutoryTabLabel} — ${this.statutory?.reportingDate ?? ''}`; + win.document.write( + `${title}` + + `${sheet.outerHTML}` + + '' + + '' + ); + win.document.close(); + win.focus(); + } + + /** + * The form's own styles, inlined because the print window loads no stylesheet of its own. + * + * Kept to what the form needs to read correctly on paper: the rules that carry meaning — the part + * banners, the totals, the column alignment — rather than a copy of the screen. Backgrounds are + * forced to print, because a part banner dropped to save ink takes the form's structure with it. + */ + private static readonly PRINT_STYLES = ` + @page { size: A4 landscape; margin: 12mm; } + * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + body { font-family: Roboto, Arial, sans-serif; color: #212121; margin: 0; } + .f1s-sheet { border: 1px solid #212121; padding: 16px; } + .f1s-sheet__head { text-align: center; border-bottom: 2px solid #212121; padding-bottom: 10px; margin-bottom: 12px; } + .f1s-sheet__title { font-weight: 700; font-size: 15px; text-transform: uppercase; } + .f1s-sheet__ref { font-size: 11px; color: #616161; margin-top: 3px; } + table { width: 100%; border-collapse: collapse; font-size: 11px; } + th, td { border: 1px solid #9e9e9e; padding: 5px 8px; vertical-align: top; } + th { background: #eee; font-weight: 700; font-size: 10.5px; text-align: center; border-color: #212121; } + .f1s-table__particulars { text-align: left; width: 52%; } + td.f1s-table__num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } + .f1s-table__colnote { display: block; margin-top: 2px; font-weight: 400; font-size: 8.5px; + text-transform: uppercase; color: #616161; } + /* The form nests to three levels — (a), then (i), then the figure. Flattened, a line reads as + belonging to the wrong head and the sub-totals below stop making sense. */ + .f1s-indent-1 { padding-left: 24px; } + .f1s-indent-2 { padding-left: 44px; } + .f1s-part td { background: #1b5e9e; color: #fff; font-weight: 700; border-color: #1b5e9e; } + .f1s-heading td { background: #f5f5f5; font-weight: 700; } + .f1s-total td { font-weight: 700; background: #fafafa; } + .f1s-total--result td { background: #e6f4ea; } + /* A shortfall reads as one rather than as a number with a minus sign — which on a filed form + is the difference between compliant and not. */ + .f1s-neg { color: #c62828; } + .f1s-subnote td { font-size: 9.5px; font-style: italic; color: #616161; background: #fffdf5; } + /* The form is signed, so the space for it belongs on the print. */ + .f1s-sign { margin-top: 48px; text-align: right; } + .f1s-sign__line { display: inline-block; border-top: 1px solid #212121; padding-top: 5px; + font-size: 11px; min-width: 210px; text-align: center; } + thead { display: table-header-group; } + tr { break-inside: avoid; } + /* An unmapped head reports nil, and a nil that nobody configured is a different thing from a + nil the bank actually holds. Kept on the printed form so a reviewer can tell them apart. */ + .f1s-badge { display: inline-block; margin-left: 6px; padding: 0 4px; font-size: 8px; + font-weight: 700; text-transform: uppercase; color: #8a6100; background: #fff3cd; + border: 1px solid #e0c56b; border-radius: 3px; } + /* A form that is not ready to file says so on the paper too. Dropping this in print would hand + someone a statement that looks finished and carries a caveat only the screen ever showed. */ + .f1s-alert { padding: 8px 12px; border: 1px solid #e0c56b; border-radius: 4px; font-size: 10px; + margin-bottom: 12px; background: #fff8e1; color: #8a6100; } + .f1s-unmet { margin: 6px 0 0 16px; padding: 0; } + /* Screen-only chrome that has no meaning on a filed form. */ + .rr-noprint, .f1s-bar { display: none !important; } + `; + + /** + * The statutory form as the workbook RBI files it in — the CIMS R377 template. + * + * Every figure comes from the return as generated, so the workbook follows whatever dates were + * asked for: change the closes and the whole of it moves with them. Nothing here is a figure + * typed into a template by hand. + */ + async exportStatutory(): Promise { + if (!this.statutory) { + return; + } + this.generating = true; + try { + const wb = newWorkbook(); + this.addCimsGeneralInformation(wb); + this.addCimsPartA(wb); + this.addCimsOthersSheet(wb); + this.addCimsPartBandC(wb); + this.addCimsPartD(wb); + this.addCimsSecuritiesSheet(wb, 'Part C.XII. c'); + this.addCimsSecuritiesSheet(wb, 'Part D.XIV.e'); + this.addCimsDailySheet(wb, 'CRR'); + this.addCimsDailySheet(wb, 'SLR'); + this.addCimsSignatory(wb); + // The working behind the two closes, after the template's own sheets. CIMS reads the sheets + // it knows by name and ignores the rest, so carrying it costs the filing nothing and saves + // producing it again when an inspector asks. + this.addDailySheet(wb); + await downloadWorkbook(wb, `R377_Form1_${this.statutory.reportingDate}`); + } finally { + this.generating = false; + } + } + + // ── The CIMS R377 workbook ── + // + // RBI takes Form 1 through CIMS, which does not read a spreadsheet the way a person does: it + // looks for its own sheets, by its own names, carrying its own wording. A single sheet that read + // like the screen — which is what this used to export — is rejected before anyone looks at a + // figure in it. So the workbook below is the template's, line for line, and only the figures and + // the dates come from the return. Sheets this bank has nothing to put on (Part D is for scheduled + // banks) are still written, empty: CIMS checks that a sheet exists before it checks its rows. + + /** The dated columns the return reports, which every sheet below is laid out across. */ + private get cimsDates(): string[] { + return this.statutory?.columns ?? []; + } + + private addCimsGeneralInformation(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('General Information'); + sheet.columns = [ + { width: 40 }, + { width: 52 }]; + addBanner(sheet, 'General Information', 'B'); + + const start = sheet.rowCount + 1; + // Return name, code and version are the template's own and are what CIMS matches on, so they + // are the one place in this workbook where a fixed string is the correct value. + this.addKeyValue(sheet, 'Return Name', 'New_Statement of Compliance with CRR and SLR (Form - I)'); + this.addKeyValue(sheet, 'Return Code', 'R377'); + // Blank where the bank's own particulars belong. They are filed-entity details this system does + // not hold, and a guess printed on a statutory return reads as a statement of fact. + this.addKeyValue(sheet, 'Bank Name', ''); + this.addKeyValue(sheet, 'Bank Code', ''); + this.addKeyValue(sheet, 'Address of Reporting Institution', ''); + this.addKeyValue(sheet, 'RO Name', ''); + this.addKeyValue(sheet, 'Reporting Frequency', 'Monthly'); + this.addKeyValue(sheet, 'Month End Date', this.statutory?.reportingDate ?? ''); + this.addKeyValue(sheet, 'Bank Type', ''); + this.addKeyValue(sheet, 'Validation Status', ''); + this.addKeyValue(sheet, 'Return Version', 'V1.0'); + boxAllCells(sheet, start, sheet.rowCount, 2); + + // Anything that would make the return wrong to file goes on the first sheet, where it is read + // before the figures rather than found after them. + if (this.statutory?.unmetRequirements?.length) { + sheet.addRow([]); + const head = sheet.addRow(['Check before filing:']); + head.font = { bold: true, color: { argb: 'FFB06000' } }; + for (const reason of this.statutory.unmetRequirements) { + const row = sheet.addRow([reason]); + sheet.mergeCells(row.number, 1, row.number, 2); + row.alignment = { wrapText: true }; + row.font = { size: 9, color: { argb: 'FFB06000' } }; + } + } + } + + /** Part A — the liabilities and assets, Roman-numbered I through VIII, across the dated columns. */ + private addCimsPartA(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Part A'); + sheet.columns = [ + { width: 72 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; + addBanner(sheet, 'Part A', this.cimsLastColumn()); + sheet.addRow([]); + this.addCimsEndDates(sheet, 'End Date'); + + const start = sheet.rowCount + 1; + for (const line of this.statutory?.partA?.lines ?? []) { + // The form's own indentation carries its numbering — (a), then (i) — so a flattened sheet + // would lose which line sits under which head. + const row = sheet.addRow([ + `${' '.repeat(line.indent ?? 0)}${line.label}`, + ...(line.values ?? []) + ]); + row.getCell(1).alignment = { wrapText: true }; + if (line.heading || line.total) { + row.font = { bold: true }; + } + this.formatCimsFigures(row); + this.shadeCimsRow(sheet, row, line.heading === true, line.total === true || line.values !== undefined); + } + boxAllCells(sheet, start, sheet.rowCount, this.cimsDates.length + 1); + } + + /** + * Part A.II.c — the break-up of "Liabilities in India to Others: c) Others". + * + * Written with its heading and a nil total: the return does not break that head down, and a sheet + * omitted fails the upload where a sheet carrying an honest nil does not. + */ + private addCimsOthersSheet(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Part A.II.c'); + sheet.columns = [ + { width: 10 }, + { width: 50 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; + addBanner(sheet, 'Part A.II.c', this.columnLetter(this.cimsDates.length + 2)); + sheet.addRow([]); + + const start = sheet.rowCount + 1; + const last = this.cimsDates.length + 2; + // "As at the close of Business on" heads BOTH dated columns as one caption, as the template has + // it — written once and merged across them, not repeated over each. + const head = sheet.addRow([ + 'Sr.No.', + 'Particulars', + 'As at the close of Business on' + ]); + head.font = { bold: true }; + head.alignment = { wrapText: true, horizontal: 'center' }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + if (this.cimsDates.length > 1) { + sheet.mergeCells(head.number, 3, head.number, last); + } + for (let c = 3; c <= last; c++) { + head.getCell(c).fill = solidFill(XL.headerGrey); + } + + const dates = sheet.addRow([ + '', + '', + ...this.cimsDates + ]); + dates.font = { bold: true }; + dates.alignment = { horizontal: 'center' }; + for (let c = 3; c <= last; c++) { + dates.getCell(c).fill = solidFill(XL.headerGrey); + } + // Sr.No. 1, hatched, as the template carries it: the break-up is written in by hand, so the row + // has to be there to be written in. An empty sheet with only a Total says the bank reported + // nothing; a hatched row says nobody has filled it in yet. + const entry = sheet.addRow([ + 1, + '', + ...this.cimsDates.map((): null => null) + ]); + for (let c = 2; c <= last; c++) { + entry.getCell(c).fill = hatchFill(); + } + + const total = sheet.addRow([ + '', + 'Total', + ...this.cimsDates.map(() => 0) + ]); + total.font = { bold: true }; + total.getCell(1).fill = solidFill(XL.headerGrey); + total.getCell(2).alignment = { horizontal: 'center' }; + total.getCell(2).fill = solidFill(XL.headerGrey); + this.formatCimsFigures(total, 3); + for (let c = 3; c <= last; c++) { + total.getCell(c).fill = solidFill(XL.derivedBlue); + } + boxAllCells(sheet, start, sheet.rowCount, last); + } + + /** Parts B and C on one sheet, as the template has them. */ + private addCimsPartBandC(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Part B and C'); + sheet.columns = [ + { width: 72 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; + addBanner(sheet, 'Part B and C', this.cimsLastColumn()); + sheet.addRow([]); + this.addCimsEndDates(sheet, 'End Date'); + + const start = sheet.rowCount + 1; + const crr = this.statutory?.crrPercentage; + const slr = this.statutory?.slrPercentage; + + this.addCimsSection(sheet, 'Part B: Compliance with section 18 (Applicable to only non-scheduled UCBs)'); + this.addCimsFigures( + sheet, + `IX. CRR required to be maintained (prescribed ${this.cimsRate(crr)} of IV b) — ${this.cimsBaseDates()})`, + this.statutory?.partB?.required + ); + this.addCimsFigures(sheet, 'X. Cash Reserve actually maintained', this.statutory?.partB?.maintained); + + this.addCimsSection(sheet, 'Part C: Compliance with section 24 (Applicable to only non-scheduled UCBs)'); + this.addCimsFigures( + sheet, + `XI. SLR required to be maintained (prescribed ${this.cimsRate(slr)} of IV b) — ${this.cimsBaseDates()})`, + this.statutory?.partC?.required + ); + this.addCimsSection(sheet, 'XII. SLR Actually Maintained'); + // Read off Part C's own lines rather than recomputed here, so this sheet and the screen cannot + // come to differ about what made the total up. + this.addCimsFigures( + sheet, + 'a) Cash and Other balances maintained in India (Excess cash reserve maintained but the excess not from DCCBs and StCBs)', + this.cimsPartCLine('(a) Cash and other balances') + ); + this.addCimsFigures(sheet, 'b) Gold', this.cimsPartCLine('(b) Gold')); + this.addCimsFigures(sheet, 'c) Unencumbered Approved Securities', this.cimsPartCLine('(c) Unencumbered')); + this.addCimsFigures(sheet, 'Total of (XII)', this.statutory?.partC?.maintained, true); + + boxAllCells(sheet, start, sheet.rowCount, this.cimsDates.length + 1); + } + + /** + * Part D — section 24 as scheduled banks file it. + * + * This is a non-scheduled co-operative bank, which files Parts B and C; Part D is not its return + * to make. It is written with the template's rows and nil figures rather than left out, because + * what CIMS rejects is a missing sheet, not an empty one. + */ + private addCimsPartD(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Part D'); + sheet.columns = [ + { width: 72 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; + addBanner(sheet, 'Part D', this.cimsLastColumn()); + sheet.addRow([]); + this.addCimsEndDates(sheet, 'End Date'); + + const start = sheet.rowCount + 1; + // Nil throughout, because this bank does not file Part D — but shaded as the template shades + // it, so the sheet still reads the way a filer expects: blue where the form would compute a + // figure, white where a bank would write one in. + const nil = this.cimsDates.map(() => 0); + this.addCimsSection(sheet, 'Part D: Compliance with section 24 (Applicable to only scheduled UCBs)'); + this.addCimsFigures( + sheet, + 'XIII. SLR required to be maintained (prescribed % of IV as on Last Friday of the Second Preceding fortnight)', + nil + ); + this.addCimsSection(sheet, 'XIV. Assets actually maintained'); + this.addCimsFigures(sheet, 'a) Cash in hand', nil); + this.addCimsFigures( + sheet, + 'b) Balance maintained with RBI in excess of the balance required to be maintained under section 42 of the RBI Act 1934' + ); + this.addCimsFigures(sheet, 'c) Net Balance in Current Accounts (i.e. VIII)', nil); + this.addCimsFigures(sheet, 'd) Gold'); + this.addCimsFigures(sheet, 'e) Unencumbered Approved Securities $$', nil); + this.addCimsFigures(sheet, 'f) State Co-op Bank of the State'); + this.addCimsFigures(sheet, 'g) District Central Co-op Bank'); + this.addCimsFigures(sheet, 'Total of (XIV) excluding f & g', nil, true); + boxAllCells(sheet, start, sheet.rowCount, this.cimsDates.length + 1); + } + + /** + * The securities valuation sheets — Part C.XII.c and Part D.XIV.e. + * + * Four valuation columns per period, and the template repeats the whole block for each date. The + * rows are the movement of the holding over the fortnight; this return strikes a closing position + * and does not carry the movement that produced it, so the opening and the additions are written + * nil and the closing carries the figure. A reader then sees the holding that was counted, and + * sees that the movement was not reported rather than being told it was zero. + */ + private addCimsSecuritiesSheet(wb: ExcelJS.Workbook, name: string): void { + const perDate = 4; + const dates = this.cimsDates; + const sheet = wb.addWorksheet(name); + sheet.columns = [ + { width: 38 }, + ...dates.flatMap(() => Array(perDate).fill({ width: 19 })) + ]; + addBanner(sheet, name, this.columnLetter(dates.length * perDate + 1)); + sheet.addRow([]); + + const dateRow = sheet.addRow([ + '', + ...dates.flatMap((d) => [ + d, + '', + '', + '' + ]) + ]); + dateRow.font = { bold: true }; + dates.forEach((_, i) => { + const from = 2 + i * perDate; + sheet.mergeCells(dateRow.number, from, dateRow.number, from + perDate - 1); + dateRow.getCell(from).alignment = { horizontal: 'center' }; + dateRow.getCell(from).fill = solidFill(XL.auditYellow); + }); + + const start = sheet.rowCount + 1; + const head = sheet.addRow([ + '', + ...dates.flatMap(() => [ + 'Face Value', + 'Book Value', + 'Depreciation', + 'Net Value for SLR purpose (2-3)' + ]) + ]); + head.font = { bold: true }; + head.alignment = { wrapText: true, horizontal: 'center' }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + const numbers = sheet.addRow([ + '', + ...dates.flatMap(() => [ + 1, + 2, + 3, + 4 + ]) + ]); + numbers.font = { bold: true }; + numbers.alignment = { horizontal: 'center' }; + + // Part C's own securities line, so the valuation sheet totals to the figure the compliance + // sheet claimed. Depreciation is nil because the return counts securities at the value the + // ledger holds, which is already net of it. + const held = this.cimsPartCLine('(c) Unencumbered') ?? dates.map(() => 0); + const movement = (label: string, carries: boolean, closing = false) => { + const cells: (string | number)[] = [label]; + for (let i = 0; i < dates.length; i++) { + const value = carries ? (held[i] ?? 0) : 0; + cells.push(value, value, 0, value); + } + const row = sheet.addRow(cells); + row.getCell(1).fill = solidFill(XL.headerGrey); + if (closing) { + row.font = { bold: true }; + } + for (let c = 2; c <= dates.length * perDate + 1; c++) { + row.getCell(c).numFmt = '#,##0.00'; + row.getCell(c).alignment = { horizontal: 'right' }; + // The template shades the Net Value column on every row — it is the figure that counts for + // SLR — and the whole of a closing balance row, which is the line the return reads off. + const isNetValue = (c - 1) % perDate === 0; + if (closing || isNetValue) { + row.getCell(c).fill = solidFill(XL.derivedBlue); + } + } + }; + + this.addCimsSection(sheet, 'Part - I (Government Securities)'); + movement('Opening Balance', false); + movement('Addition during the fortnights', false); + movement('Deduction during the fortnights', false); + movement('Closing Balance (a)', true, true); + this.addCimsSection(sheet, 'Part - II (Other Approved Securities)'); + movement('Opening Balance', false); + movement('Addition during the fortnights', false); + movement('Deduction during the fortnights', false); + movement('Closing Balance (b)', false, true); + movement('Closing Balance (a+b)', true, true); + boxAllCells(sheet, start, sheet.rowCount, dates.length * perDate + 1); + } + + /** + * The daily maintenance sheet the template carries one of per ratio. + * + * One row a day, under the template's own columns: the base liabilities, what was required, what + * was held, and the gap. The gap is split into a Deficit column and a Surplus column rather than + * carried as one signed figure, because that is how the template reads it and a filer copying + * between the two should not have to re-sign anything. + */ + private addCimsDailySheet(wb: ExcelJS.Workbook, ratio: 'CRR' | 'SLR'): void { + const isCrr = ratio === 'CRR'; + const sheet = wb.addWorksheet(isCrr ? 'Daily CRR maintd (NS)' : 'Daily SLR maintd (S and NS)'); + sheet.columns = [ + { width: 9 }, + { width: 14 }, + { width: 26 }, + { width: 21 }, + { width: 21 }, + { width: 21 }, + { width: 21 }, + { width: 19 }, + { width: 26 }]; + addBanner( + sheet, + isCrr + ? 'Cash Reserve Ratio Amount [Applicable to only Non-Scheduled UCBs]' + : 'Statutory Liquidity Ratio Amount [Applicable to both Scheduled and Non-Scheduled UCBs]', + 'I' + ); + sheet.addRow([]); + + const start = sheet.rowCount + 1; + // The template bands the ratio's name across the four columns it governs, above their own + // headings — the first three columns are the day and its base, and belong to neither ratio. + const title = isCrr + ? 'Cash Reserve Ratio Amount [Applicable to only Non-Scheduled UCBs]' + : 'Statutory Liquidity Ratio Amount [Applicable to both Scheduled and Non-Scheduled UCBs]'; + const band = sheet.addRow([ + '', + '', + '', + title + ]); + band.font = { bold: true }; + band.alignment = { wrapText: true, horizontal: 'center' }; + sheet.mergeCells(band.number, 4, band.number, 9); + for (let c = 1; c <= 9; c++) { + band.getCell(c).fill = solidFill(XL.headerGrey); + } + + const head = sheet.addRow([ + 'Sr. No.', + 'Date', + 'Net Demand and Time Liabilities (As on the base date)', + 'Required to be maintained', + 'Actually maintained', + 'Deficit (Required-Actual)', + 'Surplus (Actual-Required)', + `${ratio} Interest (Actual Rs.)`, + 'Remarks' + ]); + head.font = { bold: true }; + head.alignment = { wrapText: true, horizontal: 'center' }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + // The first three columns are one heading spanning both rows, as the template has them. + for (const c of [ + 1, + 2, + 3 + ]) { + sheet.mergeCells(band.number, c, head.number, c); + } + + const days = this.statutory?.daily?.days ?? []; + days.forEach((day, i) => { + const required = isCrr ? day.crrRequired : day.slrRequired; + const held = isCrr ? day.crrMaintained : day.slrMaintained; + const gap = isCrr ? day.crrSurplus : day.slrSurplus; + const row = sheet.addRow([ + i + 1, + day.date, + // Blank, not zero, where an older snapshot never carried the base: a nil in this column + // would read as a day with no liabilities, which is a different claim from not knowing. + day.baseLiabilities ?? '', + required, + held, + gap < 0 ? -gap : 0, + gap > 0 ? gap : 0, + '', + // The template gives NDTL one column, and it is the base's — so the day's own liabilities + // go in Remarks rather than in a column the template does not have. A reader can still see + // which days the bank's position moved without the sheet departing from the filed shape. + this.cimsDailyRemark(day, gap) + ]); + for (let c = 3; c <= 8; c++) { + row.getCell(c).numFmt = '#,##0.00'; + row.getCell(c).alignment = { horizontal: 'right' }; + } + // A day short is the thing this sheet exists to show, and in a column of near-identical + // figures it is exactly what the eye goes past. + if (gap < 0) { + row.font = { color: { argb: 'FFC5221F' } }; + } + }); + + boxAllCells(sheet, start, sheet.rowCount, 9); + } + + /** + * What a daily row's Remarks column says: the base the day was charged on, the day's own + * liabilities, and whether it fell short. + */ + private cimsDailyRemark(day: Form1DailyRow, gap: number): string { + const parts = [`Charged on ${day.baseDate}`]; + if (day.ownLiabilities !== undefined) { + parts.push(`NDTL on the day ${day.ownLiabilities.toFixed(2)}`); + } + if (gap < 0) { + parts.push('SHORT'); + } + return parts.join('; '); + } + + private addCimsSignatory(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Signatory Information'); + sheet.columns = [ + { width: 26 }, + { width: 36 }, + { width: 36 }]; + addBanner(sheet, 'Signatory Information', 'C'); + sheet.addRow([]); + + const start = sheet.rowCount + 1; + const head = sheet.addRow([ + '', + 'Authorised Reporting Official', + 'Countersigned By' + ]); + head.font = { bold: true }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + // Left to be signed, and hatched the way the template hatches it so the blank reads as "fill + // this in" rather than as a field the return had nothing to say about. Who signs a filed return, + // and on what date, is settled when it is filed — a name pre-filled here would put a signature + // on a form nobody had agreed to sign. + for (const field of [ + 'Name', + 'Designation', + 'e-mail ID', + 'Tel. No. (O)', + 'Tel. No. (R)', + 'Place', + 'Date' + ]) { + const row = sheet.addRow([ + field, + '', + '' + ]); + row.getCell(1).font = { bold: true }; + row.getCell(1).fill = solidFill(XL.keyCell); + row.getCell(2).fill = hatchFill(); + row.getCell(3).fill = hatchFill(); + } + boxAllCells(sheet, start, sheet.rowCount, 3); + } + + /** + * The header every CIMS data sheet opens with: the dates its columns report. + * + * Yellow, as the template has it — the one thing on the sheet a filer sets, so it is the one + * thing not shaded like the figures below it. + */ + private addCimsEndDates(sheet: ExcelJS.Worksheet, label: string): void { + const row = sheet.addRow([ + label, + ...this.cimsDates + ]); + row.font = { bold: true }; + row.getCell(1).alignment = { horizontal: 'right' }; + for (let c = 2; c <= this.cimsDates.length + 1; c++) { + row.getCell(c).alignment = { horizontal: 'center' }; + row.getCell(c).fill = solidFill(XL.auditYellow); + } + } + + /** + * The template's shading, which is what makes one of its sheets readable at a glance. + * + * Three states, and the colour is the only thing that tells them apart: every label cell is grey, + * a figure the form computes is blue, and a figure a bank writes in by hand is left white. On a + * sheet where most of the numbers are 0.00 that is the whole of the information — blue says "this + * is nil because the form worked it out", white says "nobody has filled this in". An all-white + * sheet makes those two look identical. + */ + private shadeCimsRow(sheet: ExcelJS.Worksheet, row: ExcelJS.Row, heading: boolean, computed: boolean): void { + row.getCell(1).fill = solidFill(XL.headerGrey); + const last = this.cimsDates.length + 1; + for (let c = 2; c <= last; c++) { + if (heading) { + // A heading has no figures of its own; the template greys the whole band. + row.getCell(c).fill = solidFill(XL.headerGrey); + } else if (computed) { + row.getCell(c).fill = solidFill(XL.derivedBlue); + } + } + } + + private addCimsSection(sheet: ExcelJS.Worksheet, label: string): void { + const row = sheet.addRow([label]); + row.font = { bold: true }; + row.getCell(1).alignment = { wrapText: true }; + row.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + } + + /** + * One figure line across the dated columns. + * + * A line the return computes is written and shaded blue. A line it does not carry is left white + * and empty rather than filled with zeros: on Part D, which this bank does not file, a column of + * 0.00 claims the bank worked the figure out and found nothing, when in truth it never reported + * that part at all. + */ + private addCimsFigures(sheet: ExcelJS.Worksheet, label: string, values?: number[], bold = false): void { + const row = sheet.addRow([ + label, + ...(values ?? this.cimsDates.map((): null => null)) + ]); + row.getCell(1).alignment = { wrapText: true }; + if (bold) { + row.font = { bold: true }; + } + this.formatCimsFigures(row); + this.shadeCimsRow(sheet, row, false, values !== undefined); + } + + private formatCimsFigures(row: ExcelJS.Row, from = 2): void { + for (let c = from; c < from + this.cimsDates.length; c++) { + row.getCell(c).numFmt = '#,##0.00'; + row.getCell(c).alignment = { horizontal: 'right' }; + } + } + + /** + * The rate a requirement was struck at, as it is named on the form. + * + * "rate not set" rather than a blank or a zero: a requirement computed at no rate is not a + * requirement of nothing, and a statutory line that reads "0% of IV b)" states a rate the bank + * was never given. + */ + private cimsRate(rate: number | null | undefined): string { + return rate === null || rate === undefined ? 'rate not set' : `${rate}%`; + } + + /** + * The base dates the two columns are charged on, for the wording of the requirement lines. + * + * The template says "Last Friday of the Second Preceding fortnight". This bank's base rule is the + * previous month's matching fortnight close, so the dates themselves are printed instead — a + * filer can check them, where a Friday that was never used cannot be checked at all. + */ + private cimsBaseDates(): string { + const bases = this.statutory?.baseColumns ?? []; + return bases.length ? bases.join(' / ') : 'base date not stated'; + } + + /** + * Whether the bank's own liabilities moved on this day from the day before. + * + * The daily table is thirty rows that mostly repeat, and the days worth looking at are the ones + * where something changed. Marked rather than left to be spotted: a reader scanning a column of + * near-identical figures will not find the row where the fourth digit differs. + * + * The first day has nothing before it in the table, so it is never marked — it is a starting + * position, not a movement. + */ + dailyOwnMoved(day: Form1DailyRow, index: number): boolean { + if (index === 0 || day.ownLiabilities === undefined) { + return false; + } + const previous = this.statutory?.daily?.days?.[index - 1]?.ownLiabilities; + return previous !== undefined && previous !== day.ownLiabilities; + } + + /** A part's line, found by the opening of its label, as its values across the dated columns. */ + private cimsPartCLine(startsWith: string): number[] | undefined { + return this.statutory?.partC?.lines?.find((line) => (line.label ?? '').startsWith(startsWith))?.values; + } + + private cimsLastColumn(): string { + return this.columnLetter(this.cimsDates.length + 1); + } + + /** Spreadsheet column letter for a 1-based index — A, B, … Z, AA. */ + private columnLetter(index: number): string { + let n = index; + let letters = ''; + while (n > 0) { + const remainder = (n - 1) % 26; + letters = String.fromCharCode(65 + remainder) + letters; + n = Math.floor((n - 1) / 26); + } + return letters; + } + + /** + * Form 1 saves the statutory form, because that is the only Form 1 this screen shows. + * + * Saved under its own form code rather than FORM_1: a saved return is rendered from its stored + * snapshot, and a statutory statement filed under the working report's code would be opened by + * the reader built for the other shape. + */ + + /** + * The day-by-day position, as its own sheet in the workbook. + * + * Written only where the return carries it, so a saved snapshot taken before this existed still + * exports rather than failing on a sheet it has no rows for. + */ + private addDailySheet(wb: ExcelJS.Workbook): void { + const daily = this.statutory?.daily; + if (!daily?.days?.length) { + return; + } + const sheet = wb.addWorksheet('Day by day'); + sheet.columns = [ + { width: 14 }, + { width: 18 }, + { width: 14 }, + { width: 18 }, + { width: 18 }, + { width: 18 }, + { width: 20 }, + { width: 18 }, + { width: 18 }, + { width: 20 }]; + addBanner(sheet, `Day by day — ${daily.from} to ${daily.to}`, 'J'); + + // A day short is a breach. It goes above the table, where it is read before the figures rather + // than hunted for among thirty rows. + if (daily.crrShortDays || daily.slrShortDays) { + const warn = sheet.addRow([ + `CRR short on ${daily.crrShortDays} of ${daily.dayCount} days; SLR short on ${daily.slrShortDays}.` + ]); + warn.font = { bold: true, color: { argb: 'FFC5221F' } }; + sheet.addRow([]); + } + + const start = sheet.rowCount + 1; + const head = sheet.addRow([ + 'Date', + 'NDTL on the day', + 'Charged on', + 'Base NDTL', + 'CRR required', + 'CRR maintained', + 'CRR surplus / (short)', + 'SLR required', + 'SLR maintained', + 'SLR surplus / (short)' + ]); + head.font = { bold: true }; + head.alignment = { wrapText: true, horizontal: 'center' }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + for (const day of daily.days) { + const row = sheet.addRow([ + day.date, + day.ownLiabilities ?? '', + day.baseDate, + day.baseLiabilities ?? '', + day.crrRequired, + day.crrMaintained, + day.crrSurplus, + day.slrRequired, + day.slrMaintained, + day.slrSurplus + ]); + // Column 3 is a date, so the figures run 2, then 4 onward. + for (const c of [ + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ]) { + row.getCell(c).numFmt = '#,##0.00'; + row.getCell(c).alignment = { horizontal: 'right' }; + } + // A shortfall is marked on the row, not only by its sign: a minus in a column of figures is + // the easiest thing on a printed sheet to read past. + if (day.crrSurplus < 0 || day.slrSurplus < 0) { + row.font = { color: { argb: 'FFC5221F' } }; + } + } + + sheet.addRow([]); + // Under the Maintained columns the averages belong to — column 6 for CRR, column 9 for SLR. + const avg = sheet.addRow([ + 'Average held', + '', + '', + '', + '', + daily.crrAverage, + '', + '', + daily.slrAverage, + '' + ]); + avg.font = { bold: true }; + avg.getCell(6).numFmt = '#,##0.00'; + avg.getCell(9).numFmt = '#,##0.00'; + boxAllCells(sheet, start, sheet.rowCount, 10); + } + + /** One of a part's computed rows, written only where the part carries it. */ + private addStatutoryTotals(sheet: ExcelJS.Worksheet, label: string, values?: number[]): void { + if (!values?.length) { + return; + } + const row = sheet.addRow([ + label, + ...values + ]); + row.font = { bold: true }; + // The values themselves say how many columns there are, so this row formats what it wrote. + const lastColumn = values.length + 1; + for (let c = 2; c <= lastColumn; c++) { + row.getCell(c).numFmt = '#,##0.00'; + row.getCell(c).alignment = { horizontal: 'right' }; + } + } +} diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html new file mode 100644 index 000000000..ddf57dc2c --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -0,0 +1,1116 @@ + + +
+ + + @if (hasStatutoryForm) { +
+ + +
+ } + + + @if (hasStatutoryForm) { + + } @else if (awaitingDate) { +
+

{{ promptTitle }}

+

+ Choose the date the return is made up to. The base is read at an earlier date than the balances maintained, and + the return states both on its face once it runs. +

+ + +
+ @if (hasBaseDate) { + + Base date (optional) + + + } + + Reporting date + + + +
+ + @if (chosenFortnight) { +

+ Fortnight: {{ chosenFortnight }} + @if (hasBaseDate && !baseDate) { + · base will be derived from the 45-day lag + } +

+ } + + +
+ Jump to: + + + @if (hasBaseDate && baseDate) { + + } +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } +
+ } @else if (loading) { +
Computing from the ledger…
+ } @else if (errorMsg) { +
{{ errorMsg }}
+ } @else if (data) { + +
+
+

{{ data.formName }}

+ {{ data.frequency }} + @if (data.fortnightLabel) { + + {{ data.fortnightLabel }} · {{ data.fortnightStart }} to {{ data.fortnightEnd }} + + } @else { + Month ended {{ data.reportingDate }} + } +
+ +
+ + @if (hasBaseDate) { + + Base date + + + } + + Reporting date + + + + + + +
+
+ + +
+ + + +
+ + @if (activeTab === 'report') { + + @if (data.unmetRequirements.length) { +
+ + {{ data.computable ? 'Check this before filing.' : 'This return cannot be computed yet.' }} + +
    + @for (reason of data.unmetRequirements; track reason) { +
  • {{ reason }}
  • + } +
+ + Open RBI Reserve Configuration → + +
+ } + + +
+
+
+ DTL base as at + {{ data.basis.dtlAsOnDate }} +
+
+
+ Balances maintained as at + {{ data.basis.holdingsAsOnDate }} +
+ @if (data.basis.rawLagDate) { +
+ {{ data.basis.lagDays }}-day lag falls on + {{ data.basis.rawLagDate }} +
+ } +
+

{{ data.basis.note }}

+
+ + + @if (data.computable) { +
+ +
+ NDTL base + {{ amount(data.ndtlAmount) }} + + DTL {{ amount(data.dtlAmount) }} less {{ amount(data.interBankAssets) }} · as at + {{ data.basis.dtlAsOnDate }} + +
+
+ {{ data.rateType }} rate + {{ data.ratePercentage }}% + @if (data.rateIsSeedDefault) { + Placeholder — not confirmed + } @else { + in force on {{ data.reportingDate }} + } +
+
+ Required + {{ amount(data.requiredAmount) }} + {{ data.ratePercentage }}% of NDTL +
+
+ Maintained + {{ amount(data.maintainedAmount) }} + {{ percent(data.maintainedPercentage) }} of NDTL +
+
+ {{ isShortfall ? 'Shortfall' : 'Surplus' }} + {{ amount(shortfallAmount) }} + {{ isShortfall ? 'Below requirement' : 'Above requirement' }} +
+
+ } + + +
+ + + + + + + + + + + + + @for (line of data.lines; track $index) { + + + + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + + + + } + } + + @if (data.computable) { + + + + + + + + + + + + + + + + + + + } + +
Particulars{{ data.baseDate || 'Base date' }}{{ data.currentDate || 'Reporting date' }}Movement
+ {{ line.label }} + @if (!line.configured) { + Not configured + } + + @if (line.configured) { + {{ + amount( + line.baseAmount !== undefined + ? line.baseAmount + : line.asOnDate === data.baseDate + ? line.amount + : null + ) + }} + } @else { + — + } + + @if (line.configured) { + {{ + amount( + line.currentAmount !== undefined + ? line.currentAmount + : line.asOnDate === data.currentDate + ? line.amount + : null + ) + }} + } @else { + — + } + + @if (line.movement !== undefined && line.movement !== null) { + {{ amount(line.movement) }} + } @else { + — + } +
+ {{ account.glCode }} + {{ account.name }} + + @if (account.excluded) { + excluded — {{ account.excluded }} + } + + @if (isPartial(account)) { + + + {{ account.weightPercentage }}% of {{ portionOf(account) }} + + } + + @if (account.liabilityClass) { + {{ account.liabilityClass | titlecase }} + } + + @if ( + account.baseBalance !== undefined ? account.baseBalance !== null : line.asOnDate === data.baseDate + ) { + {{ amount(account.baseBalance !== undefined ? account.baseBalance : account.balance) }} + } @else { + — + } + + @if ( + account.currentBalance !== undefined + ? account.currentBalance !== null + : line.asOnDate === data.currentDate + ) { + {{ amount(account.currentBalance !== undefined ? account.currentBalance : account.balance) }} + } @else { + — + } + + @if (account.movement !== undefined && account.movement !== null) { + {{ amount(account.movement) }} + } +
{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL){{ amount(data.requiredAmount) }}
{{ data.rateType }} maintained{{ amount(data.maintainedAmount) }}
{{ isShortfall ? 'Shortfall' : 'Surplus' }}{{ amount(shortfallAmount) }}
+
+ + + @if (data.maintenance; as m) { +
+

+ Daily maintenance — {{ m.periodLabel || 'this month' }} + @if (!m.fortnightComplete) { + In progress — {{ m.dayCount }} days so far + } +

+

+ Every day of the month, against the requirement in force. CRR is reckoned over the period rather than on one + day: the full requirement must be held on average, and at least + {{ m.dailyMinimumPercent }}% of it on every single day. +

+ +
+
+ Average maintained + {{ amount(m.average) }} + + {{ m.averageMet ? 'Meets' : 'Below' }} required {{ amount(m.requiredAverage) }} + +
+
+ Lowest day + {{ amount(m.lowest) }} + on {{ m.lowestOn }} · floor {{ amount(m.dailyMinimumAmount) }} +
+
+ Days counted + {{ m.dayCount }} + {{ m.fortnightComplete ? 'Full fortnight' : 'To reporting date' }} +
+
+ + + + + + + + + + + + + @for (day of m.days; track day.date) { + + + + + + + + } + +
DateRequired (INR)Actual (INR)Surplus / (Shortfall)Against floor
{{ day.date }}{{ amount(day.required) }}{{ amount(day.amount) }}{{ amount(day.surplus) }} + @if (day.amount < m.dailyMinimumAmount) { + Below floor + } @else { + OK + } +
+
+ } + + + @for (section of namedSections; track section.title) { + @if (section.rows.length) { +
+

{{ section.title }}

+

{{ section.hint }}

+ + + + + + + + + @for (row of section.rows; track row.configKey) { + + + + + } + +
ParticularsAmount (INR)
+ {{ row.label }} + @if (!row.configured) { + Not configured + } + + @if (row.configured) { + {{ amount(row.amount) }} + } @else { + — + } +
+
+ } + } + } + } + + + @if (activeTab === 'statutory') { + @if (statutoryAwaitingDate) { +
+

{{ statutoryTitle }}

+

{{ statutorySubtitle }}

+ + @if (statutoryTakesTwoColumns) { + +
+ + + First column + + + + Second column + + +
+ + +
+ + Base for first column + + + + Base for second column + + +
+ + +
+ +
+ + @if (statutoryFirstColumn && statutorySecondColumn) { +

+ Made up to {{ statutorySecondColumn }}. The base dates above are the matching close one month earlier — change either one if the bank files on + a different base. +

+ } @else { +

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

+ } + + +
+ Fill: + + @if (statutoryFirstColumn || statutorySecondColumn) { + + } +
+ } @else { + +
+ + Reporting date + + + +
+ @if (statutoryDate) { +

+ The return reports the month ending on or before {{ statutoryDate }}, and charges the ratio on the matching close one month earlier. +

+ } + } + + @if (statutoryError) { +
{{ statutoryError }}
+ } +
+ } @else if (statutoryLoading) { +
Computing from the ledger…
+ } @else if (statutoryError) { +
{{ statutoryError }}
+ } @else if (statutory) { +
+ Made up to {{ statutory.reportingDate }} + + + + + +
+ + +
+
+
{{ statutory.formName }}
+
{{ statutory.statutoryReference }}
+
+ + @if (statutory.unmetRequirements.length) { +
+ Check before filing. +
    + @for (reason of statutory.unmetRequirements; track reason) { +
  • {{ reason }}
  • + } +
+
+ } + + + + + + + @for (col of statutory.columns; track col; let i = $index) { + + } + + + + @for (part of statutoryParts; track part.title) { + + + + @for (line of part.lines; track line.label) { + @if (line.heading) { + + + + } @else { + + + @for (v of line.values; track $index) { + + } + + + @if (line.baseDates?.length) { + + + @for (d of line.baseDates; track $index) { + + } + + } + } + } + @if (part.surplus?.length) { + + + @for (s of part.surplus; track $index) { + + } + + } + } + +
Particulars + {{ col }} + fortnight close + @if (statutory.baseColumns?.[i]) { + charged on {{ statutory.baseColumns[i] }} + } +
{{ part.title }}
{{ line.label }}
+ {{ line.label }} + @if (line.configKey && line.configured === false) { + Not mapped + } + {{ amount(v) }}
Base dates — the matching close one month earlier{{ d }}
Surplus / (Shortfall){{ amount(s) }}
+ +
+
AUTHORISED SIGNATORY
+
+
+ + + @if (statutory.daily?.days?.length) { +
+

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

+

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

+ +
+
+ CRR average held + {{ amount(statutory.daily!.crrAverage) }} + + lowest {{ amount(statutory.daily!.crrLowest) }} on {{ statutory.daily!.crrLowestOn }} + +
+
+ SLR average held + {{ amount(statutory.daily!.slrAverage) }} + + lowest {{ amount(statutory.daily!.slrLowest) }} on {{ statutory.daily!.slrLowestOn }} + +
+
+ Days counted + {{ statutory.daily!.dayCount }} + to {{ statutory.daily!.to }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + @for (day of statutory.daily!.days; track day.date) { + + + + + + + + + + + + + + } + +
DateNet demand & time liabilitiesSection 18 — CRRSection 24 — SLR
On the dayCharged onBase amountRequiredMaintainedSurplus / (short)RequiredMaintainedSurplus / (short)
{{ day.date }} + {{ day.ownLiabilities === undefined ? '—' : amount(day.ownLiabilities) }} + {{ day.baseDate }} + {{ day.baseLiabilities === undefined ? '—' : amount(day.baseLiabilities) }} + {{ amount(day.crrRequired) }}{{ amount(day.crrMaintained) }}{{ amount(day.crrSurplus) }}{{ amount(day.slrRequired) }}{{ amount(day.slrMaintained) }}{{ amount(day.slrSurplus) }}
+
+ } + } + } + + + @if (activeTab === 'saved') { +
+

+ Saved returns + @if (savedReturns.length) { + {{ savedReturns.length }} + } +

+

+ Returns kept for the record. Each opens exactly as it was saved — the stored statement, not a re-run of today's + ledger, so a figure already reported cannot change underneath it. +

+ + + @if (canSave) { +
+ + Note (optional) + + + +
+ } + + @if (savedLoading) { +
Loading saved returns…
+ } @else if (!savedReturns.length) { +
Nothing saved yet. Generate a return and press Save to keep it.
+ } @else { + + + + + + + + + + + + + + + @for (row of savedReturns; track row.id) { + + + + + + + + + + + } + +
Reporting datePeriodNDTLRequiredMaintainedStatusSaved
{{ row.reportingDate }}{{ row.periodStart }} → {{ row.periodEnd }}{{ amount(row.ndtlAmount) }}{{ amount(row.requiredAmount) }}{{ amount(row.maintainedAmount) }} + + + @for (s of filingStatuses; track s) { + {{ statusLabel(s) }} + } + + + {{ row.createdOn }}
{{ row.createdBy }}
+ + +
+ } +
+ + + @if (viewingSaved && savedData; as _) { +
+
+
+

+ Saved return — {{ savedData.reportingDate }} + {{ statusLabel(viewingSaved.status) }} +

+

+ Saved {{ viewingSaved.createdOn }} by {{ viewingSaved.createdBy || 'unknown' }}. These are the figures as + they stood when it was saved — not today's ledger, which may have moved since. + @if (viewingSaved.remarks) { + “{{ viewingSaved.remarks }}” + } +

+
+ +
+ + + @if (savedIsStatutory && savedStatutory; as saved) { +
+
+
{{ saved.formName }}
+
{{ saved.statutoryReference }}
+
+ + + + + + @for (col of saved.columns; track col; let i = $index) { + + } + + + + @for (part of savedStatutoryParts; track part.title) { + + + + @for (line of part.lines; track line.label) { + + + @for (value of line.values || []; track $index) { + + } + + } + } + +
Particulars + {{ col }} + + {{ saved.columns.length > 2 && i === 2 ? 'base for ratios' : 'fortnight close' }} + + @if (saved.baseColumns?.[i]) { + charged on {{ saved.baseColumns[i] }} + } +
{{ part.title }}
+ {{ line.label }} + {{ amount(value) }}
+
+ } @else { +
+
+ NDTL base + {{ amount(savedData.ndtlAmount ?? savedData.dtlAmount) }} + as at {{ savedData.basis.dtlAsOnDate }} +
+
+ {{ savedData.rateType }} rate + {{ savedData.ratePercentage }}% + as filed +
+
+ Required + {{ amount(savedData.requiredAmount) }} + {{ savedData.ratePercentage }}% of NDTL +
+
+ Maintained + {{ amount(savedData.maintainedAmount) }} + on {{ savedData.basis.holdingsAsOnDate }} +
+
+ {{ savedData.compliant ? 'Surplus' : 'Shortfall' }} + {{ amount(savedSurplus) }} + {{ savedData.compliant ? 'Above requirement' : 'Below requirement' }} +
+
+ + + + + + + + + + + @for (line of savedData.lines; track line.configKey) { + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + + } + } + +
ParticularsAs atAmount (INR)
+ {{ line.label }} + {{ line.asOnDate }} + @if (line.configured) { + {{ amount(line.amount) }} + } @else { + — + } +
+ {{ account.glCode }} + {{ account.name }} + @if (isPartial(account)) { + + {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} + + } + @if (account.liabilityClass) { + {{ account.liabilityClass | titlecase }} + } + {{ amount(account.balance) }}
+ + @if (savedData.maintenance; as sm) { +

Daily maintenance as filed — {{ sm.periodLabel || '' }}

+ + + + + + + + + + + @for (day of sm.days; track day.date) { + + + + + + + } + +
DateRequiredActualSurplus / (Shortfall)
{{ day.date }}{{ amount(day.required) }}{{ amount(day.amount) }}{{ amount(day.surplus) }}
+ } + } +
+ } + } + + @if (toastVisible) { +
{{ toastMsg }}
+ } +
diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.scss b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss new file mode 100644 index 000000000..6a550b856 --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss @@ -0,0 +1,704 @@ +/** + * 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; + +.rr-container { + max-width: 1120px; + margin: 0 auto; + padding: 20px 16px; +} + +.rr-loading { + padding: 36px; + text-align: center; + color: $gray-500; + font-size: 13px; +} + +.rr-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; + } +} + +.rr-unmet { + margin: 8px 0 8px 18px; + padding: 0; + + li { + margin-bottom: 4px; + } + + // Sets up the list that follows: without it, a blank line reads as an error rather than as a + // head the bank has nothing under. + &__lead { + margin: 6px 0 0; + font-size: 12px; + font-weight: 400; + line-height: 1.6; + opacity: 0.9; + } + + &__link { + display: inline-block; + margin-top: 4px; + font-weight: 600; + color: $primary; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +// ── Header ── + +.rr-header { + 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; + } + + &__freq { + display: inline-block; + margin-top: 4px; + margin-right: 10px; + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + background: #e8f0fe; + color: $primary; + border-radius: 20px; + padding: 3px 10px; + } + + &__period { + font-size: 12px; + color: $gray-500; + } + + &__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + + i { + margin-right: 6px; + } + } +} + +.rr-date { + width: 180px; +} + +// ── Tabs ── + +.rr-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid $border; + margin-bottom: 16px; +} + +.rr-tab { + display: inline-flex; + align-items: center; + gap: 8px; + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 10px 16px; + font-size: 13px; + font-weight: 600; + color: $gray-500; + cursor: pointer; + font-family: inherit; + + i { + font-size: 12px; + } + + &:hover { + color: $gray-900; + } + + &--on { + color: $primary; + border-bottom-color: $primary; + } + + &__count { + font-size: 10.5px; + font-weight: 700; + background: #e8f0fe; + color: $primary; + border-radius: 20px; + padding: 1px 8px; + } +} + +// ── Basis ── + +.rr-basis { + background: $gray-50; + border: 1px solid $border; + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 16px; + + &__title { + margin: 0 0 12px; + font-size: 13px; + font-weight: 700; + color: $gray-900; + } + + &__row { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + } + + // The General Information particulars. A grid rather than a stack: eleven one-line facts read as + // a block to scan, not as a list to work down, and auto-fit keeps them legible at any width. + &__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px 20px; + } + + &__item { + min-width: 0; + + &--muted { + opacity: 0.65; + } + } + + &__label { + display: block; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + } + + &__value { + font-size: 14px; + font-weight: 600; + color: $gray-900; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; + + // A particular nobody has maintained yet, shown as such rather than as a filled-in blank. + &--unset { + font-weight: 500; + font-style: italic; + color: $amber; + } + } + + &__arrow { + color: $gray-500; + font-size: 12px; + } + + &__note { + margin: 10px 0 0; + font-size: 11.5px; + color: $gray-500; + line-height: 1.6; + } +} + +// ── Summary stats ── + +.rr-summary { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; +} + +.rr-stat { + flex: 1 1 180px; + background: #fff; + border: 1px solid $border; + border-radius: 10px; + padding: 12px 14px; + + &__label { + display: block; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + margin-bottom: 4px; + } + + &__value { + display: block; + font-size: 19px; + font-weight: 700; + color: $gray-900; + font-variant-numeric: tabular-nums; + } + + &__sub { + display: block; + margin-top: 2px; + font-size: 11px; + color: $gray-500; + + &--warn { + color: $amber; + font-weight: 600; + } + } + + // The rate is still the shipped placeholder — the figures below it are only as good as it is. + &--provisional { + border-color: #fde68a; + background: #fffdf5; + } + + // A shortfall is a regulatory breach, so it reads as one rather than as another number. + &--bad { + border-color: #f5c6c4; + background: #fffafa; + + .rr-stat__value { + color: $red; + } + } + + &--good { + border-color: #ceead6; + background: #fafffb; + + .rr-stat__value { + color: $green; + } + } +} + +// ── Table ── + +.rr-card { + background: #fff; + border: 1px solid $border; + border-radius: 12px; + padding: 6px 16px 14px; + overflow-x: auto; +} + +.rr-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + font-weight: 600; + color: $gray-500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 10px; + border-bottom: 1px solid $border; + white-space: nowrap; + } + + td { + padding: 9px 10px; + border-bottom: 1px solid $gray-200; + color: $gray-900; + } + + // 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 { + &__label { + font-weight: 600; + } + + &__date { + color: $gray-500; + font-size: 11.5px; + white-space: nowrap; + } + + &--unconfigured { + background: #fffafa; + } +} + +.rr-subline { + td { + padding-top: 5px; + padding-bottom: 5px; + font-size: 11.5px; + color: $gray-500; + border-bottom: none; + } + + td:first-child { + padding-left: 26px; + } + + &__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; + } + + // Only part of the account is taken, so the figure alongside is not its balance. + &__portion { + margin-left: 8px; + font-size: 10.5px; + font-weight: 600; + color: $amber; + background: #fef7e0; + border-radius: 20px; + padding: 1px 8px; + white-space: nowrap; + } + + &__class { + margin-left: 6px; + font-size: 10px; + font-weight: 600; + color: $primary; + background: #e8f0fe; + border-radius: 20px; + padding: 1px 8px; + } + + // An account pinned to the role but not in the total. Marked rather than merely blank, so it + // reads as deliberately left out instead of as an account that happens to be nil. + &--excluded td { + color: $amber; + } + + &__excluded { + font-size: 10.5px; + font-style: italic; + margin-left: 8px; + color: $amber; + } +} + +.rr-total { + td { + font-weight: 700; + background: #e8f0fe; + border-bottom: 1px solid #d2e3fc; + } + + &--bad td { + background: #fce8e6; + color: $red; + border-bottom-color: #f5c6c4; + } +} + +.rr-dash { + 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 { + margin-top: 16px; + padding: 16px; +} + +.rr-section { + &__title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 600; + color: $gray-900; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + &__hint { + margin: 0 0 14px; + font-size: 12px; + color: $gray-500; + line-height: 1.6; + } +} + +// A day below the daily floor is a breach on its own, even where the average passes. +.rr-day--short td { + background: #fffafa; +} + +.rr-ok { + font-size: 11px; + color: $green; + font-weight: 600; +} + +// A shortfall on a day reads as one, rather than as a number with a minus sign in front. +.rr-neg { + color: $red; + font-weight: 600; +} + +// ── Saved returns ── + +.rr-empty { + padding: 22px; + text-align: center; + color: $gray-500; + font-size: 12.5px; +} + +.rr-save-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; + + &__note { + flex: 1 1 340px; + } +} + +.rr-saved { + &__period, + &__meta { + font-size: 11px; + color: $gray-500; + white-space: nowrap; + } + + &__status { + width: 170px; + } + + &__actions { + white-space: nowrap; + } + + &__view { + font-size: 12px !important; + min-width: 0 !important; + color: $primary !important; + } + + &__del { + min-width: 0 !important; + padding: 0 8px !important; + color: $gray-500 !important; + + &:hover { + color: $red !important; + } + } + + &__remarks { + display: block; + margin-top: 4px; + color: $gray-500; + } + + &--open td { + background: #e8f0fe; + } +} + +// The opened snapshot, in its own section. Tinted and edged so it reads as a different document +// from the live return above it rather than as more of the same page. +.rr-card--saved { + border-color: #c6dafc; + background: #fbfdff; + border-left: 4px solid $primary; +} + +.rr-saved-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.rr-saved__subhead { + margin: 20px 0 8px; + font-size: 13px; + font-weight: 600; + color: $gray-900; +} + +// ── Print ── +// The browser's print dialog is also its save-as-PDF, so one stylesheet serves both. Controls, +// navigation and the saved-returns list are dropped: a printed return is a statement, and a page +// carrying dead buttons reads as a screenshot of software rather than as a filing. +@media print { + .rr-noprint, + .rr-header__actions, + .rr-unmet__link { + display: none !important; + } + + .rr-container { + max-width: none; + padding: 0; + } + + .rr-card, + .rr-basis, + .rr-stat { + border-color: #999 !important; + box-shadow: none !important; + break-inside: avoid; + } + + .rr-card { + overflow: visible !important; + } + + // Header rows repeat on every printed page, so a table spilling over a page break stays readable. + .rr-table thead { + display: table-header-group; + } + + .rr-table tr { + break-inside: avoid; + } + + .rr-toast { + display: none !important; + } + + // Backgrounds carry meaning here — a day below the floor is shaded, not merely labelled — so they + // must survive the print engine's default of dropping them to save ink. + .rr-container, + .rr-card, + .rr-basis, + .rr-stat, + .rr-table, + .rr-table th, + .rr-table td { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } +} + +.rr-badge { + display: inline-block; + margin-left: 8px; + font-size: 10px; + font-weight: 600; + border-radius: 20px; + padding: 2px 8px; + + &--unmapped { + background: #fce8e6; + color: $red; + } + + // The fortnight is still running, so the average is of the days elapsed so far. + &--partial { + background: #fef7e0; + color: $amber; + } +} + +.rr-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; +} + +@media (width <= 720px) { + .rr-header__actions { + width: 100%; + } + + .rr-date { + flex: 1 1 100%; + width: auto; + } +} diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index 4a6fe1a06..af43e1032 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -12,6 +12,7 @@ import { Routes, RouterModule } from '@angular/router'; /** Routing Imports */ import { Route } from '../core/route/route.service'; +import { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service'; /** Custom Components */ import { ReportsComponent } from './reports.component'; @@ -75,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' }, @@ -84,12 +101,14 @@ const routes: Routes = [ { path: 'tds-interest', data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' }, + resolve: { tdsConfig: tdsConfigResolver }, loadComponent: () => import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent) }, { path: 'tds-remittance', data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' }, + resolve: { tdsConfig: tdsConfigResolver }, loadComponent: () => import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent) }, diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index db236d881..ff2758f25 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -907,11 +907,13 @@ } /* Where the rating should land, read straight off the Current Risk column beside it. */ +/* stylelint-disable-next-line no-duplicate-selectors */ .rr-move-cell { min-width: 150px; max-width: 190px; } +/* stylelint-disable-next-line no-duplicate-selectors */ .rr-move { display: inline-flex; align-items: center; @@ -922,12 +924,14 @@ font-weight: 700; white-space: nowrap; + /* stylelint-disable-next-line no-duplicate-selectors */ &--up { background: var(--r-red-l); color: var(--r-red); } /* Coming down is a relief, not an alarm — same green the verdict uses. */ + /* stylelint-disable-next-line no-duplicate-selectors */ &--down { background: var(--r-green-l); color: var(--r-green); diff --git a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html index e121e3be3..7084e2a16 100644 --- a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html +++ b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html @@ -21,7 +21,7 @@
@@ -72,7 +72,7 @@
@@ -81,7 +81,7 @@
@@ -143,7 +143,7 @@
diff --git a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts index 769771faf..0b3948c00 100644 --- a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts +++ b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts @@ -26,6 +26,8 @@ export class ViewSavingsAccountingDetailsComponent { @Input() accountingRule: OptionData; @Input() accountingMappings: any[] = []; + /** Product name for GL row titles (e.g. 'Fixed deposit'); omitted = savings wording. */ + @Input() productLabel?: string; isCashOrAccrualAccounting(): boolean { if (this.accountingRule) { diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index 0299ae92f..ee9cf450c 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html @@ -226,8 +226,10 @@ Submitting… } @else { - check - Submit Request + + check + Submit Request + } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html index 4a91e4673..9657f6992 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html @@ -275,8 +275,10 @@ Applying… } @else { - check - Apply Collection + + check + Apply Collection + } diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts index d2f1315f5..debedb2fe 100644 --- a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts +++ b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts @@ -287,7 +287,11 @@ export class GlSettingsDialogComponent implements OnInit { this.sponsorDDService .upsertGlMapping({ sponsorBankId: 0, - ddPayableGlAccountId: val.ddPayableGlAccountId, + // DD Payable is set per sponsor bank (Organization > Sponsor Banks) and is the only source + // DD posting reads, so this screen no longer owns it. We still round-trip whatever the row + // already had -- so an existing value is preserved -- but a tenant that never set it (NULL) + // now saves fine instead of hitting "Sponsor DD Payable GL account is required". + ddPayableGlAccountId: this.mapping?.ddPayableGlAccountId ?? null, chequeClearingGlAccountId: val.chequeClearingGlAccountId, cashGlAccountId: val.cashGlAccountId, chargesIncomeGlAccountId: val.chargesIncomeGlAccountId, @@ -297,8 +301,14 @@ export class GlSettingsDialogComponent implements OnInit { ddLimitAmount: val.ddLimitAmount }) .subscribe({ - next: () => { - this.snackBar.open('Sponsor DD GL Mapping & GST Settings saved successfully!', 'Close', { duration: 3000 }); + next: (res: any) => { + // A save submitted for approval comes back HTTP 200 with rollbackTransaction: true - the + // mapping has not actually been applied yet, so this must not claim success the same way an + // immediate save does. + const message = res?.rollbackTransaction + ? 'Sponsor DD GL Mapping & GST Settings submitted for approval' + : 'Sponsor DD GL Mapping & GST Settings saved successfully!'; + this.snackBar.open(message, 'Close', { duration: res?.rollbackTransaction ? 4000 : 3000 }); this.dialogRef.close(true); }, error: (err) => { diff --git a/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts b/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts index 5a55ebe6c..1cf87d78a 100644 --- a/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts +++ b/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts @@ -20,6 +20,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { AlertService } from 'app/core/alert/alert.service'; @Component({ selector: 'mifosx-sponsor-bank-list', @@ -356,6 +357,7 @@ export class AddSponsorBankDialogComponent implements OnInit { private http = inject(HttpClient); private dialogRef = inject(MatDialogRef); private snackBar = inject(MatSnackBar); + private alertService = inject(AlertService); /** The bank being edited, or null when adding a new one. */ data = inject(MAT_DIALOG_DATA, { optional: true }) as any; @@ -419,10 +421,24 @@ export class AddSponsorBankDialogComponent implements OnInit { ? this.http.put(`/sponsorbanks/${this.data.bank.id}`, this.form.value) : this.http.post('/sponsorbanks', this.form.value); request$.subscribe({ - next: () => { - this.snackBar.open(this.isEdit ? 'Sponsor Bank updated' : 'Sponsor Bank added successfully', 'Close', { - duration: 3000 - }); + next: (res: any) => { + // A create/update submitted for approval comes back HTTP 200 with rollbackTransaction: true - + // the bank has not actually been created/changed yet, so this must not claim success the same + // way an immediate create/update does. Routed through AlertService (not the local snackBar) + // so it uses the same top-right notification as the Payment Type/Working Days Maker-Checker + // flows, instead of the bottom-positioned snackbar used for this dialog's other messages. + if (res?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: this.isEdit + ? 'Sponsor Bank update has been submitted for approval' + : 'Sponsor Bank creation has been submitted for approval' + }); + } else { + this.snackBar.open(this.isEdit ? 'Sponsor Bank updated' : 'Sponsor Bank added successfully', 'Close', { + duration: 3000 + }); + } this.dialogRef.close(true); }, error: (err) => { diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index e811f563a..bb0c52a1e 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -172,7 +172,8 @@ export class SponsorDDService { upsertGlMapping(data: { sponsorBankId: number; - ddPayableGlAccountId: number; + // DD Payable GL is configured per sponsor bank now, not on this screen, so it is optional here. + ddPayableGlAccountId?: number | null; chequeClearingGlAccountId?: number; cashGlAccountId?: number; chargesIncomeGlAccountId?: number; diff --git a/src/app/system/account-number-preferences/account-number-preferences.component.html b/src/app/system/account-number-preferences/account-number-preferences.component.html index d16691645..4a234addc 100644 --- a/src/app/system/account-number-preferences/account-number-preferences.component.html +++ b/src/app/system/account-number-preferences/account-number-preferences.component.html @@ -7,7 +7,12 @@ -->
- diff --git a/src/app/system/account-number-preferences/create-account-number-preference/create-account-number-preference.component.ts b/src/app/system/account-number-preferences/create-account-number-preference/create-account-number-preference.component.ts index 31276169b..f217a7e3b 100644 --- a/src/app/system/account-number-preferences/create-account-number-preference/create-account-number-preference.component.ts +++ b/src/app/system/account-number-preferences/create-account-number-preference/create-account-number-preference.component.ts @@ -14,6 +14,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router'; /** Custom Services */ import { SystemService } from '../../system.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Create Account Number Preference Component. @@ -31,6 +32,7 @@ export class CreateAccountNumberPreferenceComponent implements OnInit { private systemService = inject(SystemService); private route = inject(ActivatedRoute); private router = inject(Router); + private alertService = inject(AlertService); /** Account Number Preferences Form */ accountNumberPreferenceForm: UntypedFormGroup; @@ -96,6 +98,14 @@ export class CreateAccountNumberPreferenceComponent implements OnInit { accountNumberPreference.prefixType = undefined; } this.systemService.createAccountNumberPreference(accountNumberPreference).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Account number preference creation has been submitted for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + return; + } this.router.navigate( [ '../', diff --git a/src/app/system/account-number-preferences/edit-account-number-preference/edit-account-number-preference.component.ts b/src/app/system/account-number-preferences/edit-account-number-preference/edit-account-number-preference.component.ts index 134eb1134..d29c50971 100644 --- a/src/app/system/account-number-preferences/edit-account-number-preference/edit-account-number-preference.component.ts +++ b/src/app/system/account-number-preferences/edit-account-number-preference/edit-account-number-preference.component.ts @@ -14,6 +14,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; /** Custom Services */ import { SystemService } from 'app/system/system.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit Account Number Preference Component. @@ -31,6 +32,7 @@ export class EditAccountNumberPreferenceComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private systemService = inject(SystemService); private router = inject(Router); + private alertService = inject(AlertService); /** Account Number Preference Form */ accountNumberPreferenceForm: UntypedFormGroup; @@ -90,6 +92,12 @@ export class EditAccountNumberPreferenceComponent implements OnInit { this.systemService .updateAccountNumberPreference(this.accountNumberPreferenceData.id, accountNumberPreferenceValue) .subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Account number preference update has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/account-number-preferences/view-account-number-preference/view-account-number-preference.component.ts b/src/app/system/account-number-preferences/view-account-number-preference/view-account-number-preference.component.ts index e67996982..78e7e5743 100644 --- a/src/app/system/account-number-preferences/view-account-number-preference/view-account-number-preference.component.ts +++ b/src/app/system/account-number-preferences/view-account-number-preference/view-account-number-preference.component.ts @@ -19,6 +19,7 @@ import { TranslateService } from '@ngx-translate/core'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * View Account Number Preference Component. @@ -38,6 +39,7 @@ export class ViewAccountNumberPreferenceComponent { private router = inject(Router); private dialog = inject(MatDialog); private translateService = inject(TranslateService); + private alertService = inject(AlertService); /** Account Number Preference Data */ accountNumberPreferenceData: any; @@ -70,9 +72,17 @@ export class ViewAccountNumberPreferenceComponent { }); deleteAccountNumberPreferenceDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.systemService.deleteAccountNumberPreference(this.accountNumberPreferenceData.id).subscribe(() => { - this.router.navigate(['/system/account-number-preferences']); - }); + this.systemService + .deleteAccountNumberPreference(this.accountNumberPreferenceData.id) + .subscribe((deleteResponse: any) => { + if (deleteResponse?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Account number preference deletion has been submitted for approval.' + }); + } + this.router.navigate(['/system/account-number-preferences']); + }); } }); } diff --git a/src/app/system/codes/create-code/create-code.component.ts b/src/app/system/codes/create-code/create-code.component.ts index e933d4f34..9ade49035 100644 --- a/src/app/system/codes/create-code/create-code.component.ts +++ b/src/app/system/codes/create-code/create-code.component.ts @@ -17,6 +17,7 @@ import { PopoverService } from '../../../configuration-wizard/popover/popover.se import { PopoverRef } from '../../../configuration-wizard/popover/popover-ref'; import { ConfigurationWizardService } from '../../../configuration-wizard/configuration-wizard.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; @Component({ selector: 'mifosx-create-code', @@ -33,6 +34,7 @@ export class CreateCodeComponent implements OnInit, AfterViewInit { private router = inject(Router); private configurationWizardService = inject(ConfigurationWizardService); private popoverService = inject(PopoverService); + private alertService = inject(AlertService); /** Code form. */ codeForm: UntypedFormGroup; @@ -67,6 +69,14 @@ export class CreateCodeComponent implements OnInit, AfterViewInit { */ submit() { this.systemService.createCode(this.codeForm.value).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Code creation has been submitted for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + return; + } if (this.configurationWizardService.showSystemCodesForm) { this.configurationWizardService.showSystemCodesForm = false; this.configurationWizardService.showRolesandPermission = true; diff --git a/src/app/system/codes/edit-code/edit-code.component.ts b/src/app/system/codes/edit-code/edit-code.component.ts index 2ec83282a..69c9e94d6 100644 --- a/src/app/system/codes/edit-code/edit-code.component.ts +++ b/src/app/system/codes/edit-code/edit-code.component.ts @@ -14,6 +14,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; /** Custom Services */ import { SystemService } from '../../system.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit Code Component. @@ -31,6 +32,7 @@ export class EditCodeComponent implements OnInit { private systemService = inject(SystemService); private route = inject(ActivatedRoute); private router = inject(Router); + private alertService = inject(AlertService); /** Code Form */ codeForm: UntypedFormGroup; @@ -75,6 +77,20 @@ export class EditCodeComponent implements OnInit { */ submit() { this.systemService.updateCode(this.codeForm.value, this.codeData.id).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Code update has been submitted for approval.' + }); + this.router.navigate( + [ + '../../', + this.codeData.id + ], + { relativeTo: this.route } + ); + return; + } this.router.navigate( [ '../../', diff --git a/src/app/system/codes/view-code/view-code.component.html b/src/app/system/codes/view-code/view-code.component.html index 80160fe68..dd029c014 100644 --- a/src/app/system/codes/view-code/view-code.component.html +++ b/src/app/system/codes/view-code/view-code.component.html @@ -33,7 +33,7 @@ mat-stroked-button color="warn" (click)="delete()" - *mifosxHasPermission="'DELETE_CODEVALUE'" + *mifosxHasPermission="'DELETE_CODE'" class="vc-header__btn-delete" > diff --git a/src/app/system/codes/view-code/view-code.component.ts b/src/app/system/codes/view-code/view-code.component.ts index ef05aef74..52b87adf1 100644 --- a/src/app/system/codes/view-code/view-code.component.ts +++ b/src/app/system/codes/view-code/view-code.component.ts @@ -30,6 +30,7 @@ import { MatCard, MatCardContent } from '@angular/material/card'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * View Code Component. @@ -53,6 +54,7 @@ export class ViewCodeComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private dialog = inject(MatDialog); private translateService = inject(TranslateService); + private alertService = inject(AlertService); /** Code Data */ codeData: any; @@ -152,6 +154,13 @@ export class ViewCodeComponent implements OnInit { deleteCodeValue(index: number) { const codeValueId = this.codeValuesData[index].id; this.systemService.deleteCodeValue(this.codeData.id, codeValueId).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Code value deletion has been submitted for approval.' + }); + return; + } this.codeValuesData.splice(index, 1); this.codeValues.removeAt(index); this.codeValueRowStatus.splice(index, 1); @@ -177,6 +186,13 @@ export class ViewCodeComponent implements OnInit { this.systemService .updateCodeValue(this.codeData.id, this.codeValuesData[index].id, updatedCodeValue) .subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Code value update has been submitted for approval.' + }); + return; + } this.codeValues.at(index).disable(); this.codeValueRowStatus[index] = 'disabled'; this.codeValues.at(index).markAsPristine(); @@ -192,7 +208,13 @@ export class ViewCodeComponent implements OnInit { }); deleteCodeDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.systemService.deleteCode(this.codeData.id).subscribe(() => { + this.systemService.deleteCode(this.codeData.id).subscribe((deleteResponse: any) => { + if (deleteResponse?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Code deletion has been submitted for approval.' + }); + } this.router.navigate(['/system/codes']); }); } @@ -221,6 +243,13 @@ export class ViewCodeComponent implements OnInit { const newCodeValue: { name: string; description: string; position: string; isActive: boolean } = this.codeValues.at(index).value; this.systemService.createCodeValue(this.codeData.id, newCodeValue).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Code value creation has been submitted for approval.' + }); + return; + } this.codeValues.at(index).disable(); this.codeValueRowStatus[index] = 'disabled'; this.codeValuesData.push({ diff --git a/src/app/system/configurations/global-configurations-tab/edit-configuration/edit-configuration.component.ts b/src/app/system/configurations/global-configurations-tab/edit-configuration/edit-configuration.component.ts index 35bb17470..c2b331773 100644 --- a/src/app/system/configurations/global-configurations-tab/edit-configuration/edit-configuration.component.ts +++ b/src/app/system/configurations/global-configurations-tab/edit-configuration/edit-configuration.component.ts @@ -12,6 +12,7 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; +import { AlertService } from 'app/core/alert/alert.service'; /** Custom Services */ import { SystemService } from '../../../system.service'; @@ -37,6 +38,7 @@ export class EditConfigurationComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private dateUtils = inject(Dates); + private alertService = inject(AlertService); /** Minimum transaction date allowed. */ minDate = new Date(2000, 0, 1); @@ -122,7 +124,15 @@ export class EditConfigurationComponent implements OnInit { delete payload.dateValue; } - this.systemService.updateConfiguration(this.configuration.id, payload).subscribe(() => { + this.systemService.updateConfiguration(this.configuration.id, payload).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Configuration update has been submitted for approval.' + }); + this.router.navigate(['../../'], { relativeTo: this.route }); + return; + } this.router.navigate(['../../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts index 80a29ad45..4af539e36 100644 --- a/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts +++ b/src/app/system/configurations/global-configurations-tab/global-configurations-tab.component.ts @@ -145,15 +145,25 @@ export class GlobalConfigurationsTabComponent implements OnInit, AfterViewInit { * Enables/Disables respective configuration */ toggleStatus(configuration: any) { - this.systemService - .updateConfiguration(configuration.id, { enabled: configuration.enabled }) - .subscribe((response: any) => { - configuration.enabled = response.changes.enabled; - if (configuration.name === SettingsService.businessDateConfigName) { - const msg = configuration.enabled ? 'enabled' : 'disabled'; - this.alertService.alert({ type: SettingsService.businessDateType + ' Set Config', message: msg }); - } - }); + const desiredEnabled = configuration.enabled; + this.systemService.updateConfiguration(configuration.id, { enabled: desiredEnabled }).subscribe((response: any) => { + if (response?.rollbackTransaction) { + // Parked for checker approval - the configuration has not actually changed yet, so the + // toggle (already flipped optimistically by ngModel on click) must be reverted, and + // response.changes must not be read since a parked response never contains it. + configuration.enabled = !desiredEnabled; + this.alertService.alert({ + type: 'Success', + message: 'Configuration update has been submitted for approval.' + }); + return; + } + configuration.enabled = response.changes.enabled; + if (configuration.name === SettingsService.businessDateConfigName) { + const msg = configuration.enabled ? 'enabled' : 'disabled'; + this.alertService.alert({ type: SettingsService.businessDateType + ' Set Config', message: msg }); + } + }); } /** diff --git a/src/app/system/configure-maker-checker-tasks/configure-maker-checker-tasks.component.html b/src/app/system/configure-maker-checker-tasks/configure-maker-checker-tasks.component.html index 66fb9bf28..c2325820b 100644 --- a/src/app/system/configure-maker-checker-tasks/configure-maker-checker-tasks.component.html +++ b/src/app/system/configure-maker-checker-tasks/configure-maker-checker-tasks.component.html @@ -7,6 +7,26 @@ -->
+ + + @if (isSearchActive) { + + } +
- @@ -74,7 +71,9 @@
-
{{ 'labels.inputs.Entity' | translate }}
+ +
{{ 'labels.inputs.Entity Type' | translate }}
{{ checkerInboxDetail.entityName }}
@if (checkerInboxDetail.resourceId) { @@ -108,9 +107,61 @@
- @if (displayJSONData) { + @if (comparing) { +
+ + Comparing against current values… +
+ } + + + @if (showChanges) {
-
{{ 'labels.heading.Command' | translate }}
+
Changes Requested ({{ changes.length }})
+ @if (changes.length === 0) { +

This request does not change any value.

+ } @else { +
+ @for (change of changes; track $index) { +
+
+ {{ change.label }} + {{ + change.kind + }} +
+
+ {{ change.oldValue }} + + {{ change.newValue }} +
+
+ } +
+ } +
+ } + + + @if (!showChanges && createSections.length > 0) { + @for (section of createSections; track section.title) { +
+
{{ section.title }}
+
+ @for (field of section.fields; track $index) { + + } +
+
+ } + } + + + @if (!showChanges && createSections.length === 0 && displayJSONData) { +
+
+ {{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }} +
@for (field of commandFields; track $index) { diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss index e1d13e084..9bb961c1c 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss @@ -304,6 +304,97 @@ $bg-page: #f8fafc; color: $text-soft; } +/* ── UPDATE review: one row per field that actually moved ──────────────────── */ +.vci-changes { + display: flex; + flex-direction: column; + gap: 1px; + background: $border; +} + +.vci-change { + background: #fff; + padding: 11px 16px; + + &__head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 5px; + } + + &__label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-soft; + } + + &__kind { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.07em; + padding: 1px 7px; + border-radius: 2px; + + &--modified { + background: #e0f2fe; + color: #0369a1; + } + + &--added { + background: #dcfce7; + color: #15803d; + } + + &--removed { + background: #fee2e2; + color: #991b1b; + } + + /* Not a change: the entity has no such field, so there was no before-value to compare. */ + &--submitted { + background: #eef1f4; + color: #5b6673; + } + } + + &__values { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 14px; + min-width: 0; + } + + &__old { + color: $text-soft; + text-decoration: line-through; + overflow-wrap: anywhere; + } + + &__arrow { + color: $text-soft; + font-size: 11px; + flex: none; + } + + &__new { + color: $text-dark; + font-weight: 600; + overflow-wrap: anywhere; + } +} + +.vci-nochange { + margin: 0; + padding: 16px; + font-size: 13.5px; + color: $text-soft; +} + .vci-tag { display: inline-flex; align-items: center; diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts index cfc3cb336..ade448188 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts @@ -14,6 +14,7 @@ import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dial /** Custom Services */ import { TasksService } from '../tasks.service'; +import { SystemService } from 'app/system/system.service'; /** Dialog Components */ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; @@ -23,6 +24,24 @@ import { NgTemplateOutlet } from '@angular/common'; import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { isTempTableProductEdit } from '../product-entities'; +import { + CommandChange, + diffCommandAgainstEntity, + displayScalar, + groupCreateFields, + humanizeKey +} from '../command-diff.util'; +import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch'; + +/** Services the entity lookup table needs; assembled once and handed to the shared fetcher. */ +import { ProductsService } from 'app/products/products.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SavingsService } from 'app/savings/savings.service'; +import { AccountingService } from 'app/accounting/accounting.service'; +import { UsersService } from 'app/users/users.service'; +import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; +import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service'; +import { OrganizationService } from 'app/organization/organization.service'; /** * One row of the rendered Command payload. The dialog turns the parsed command JSON into a tree of @@ -35,6 +54,17 @@ export type CommandField = | { label: string; kind: 'object'; fields: CommandField[] } | { label: string; kind: 'list'; items: CommandField[] }; +/** + * Transport-level fields every Fineract command carries. They describe how the request was encoded, + * not what the maker actually changed, so they are dropped before the payload is shown - present in + * roughly three quarters of all stored commands, and pure noise to a checker. + */ +const NOISE_KEYS = new Set([ + 'locale', + 'dateFormat', + 'monthDayFormat' +]); + /** * Segments that read as initialisms in this domain, so `upiFacility` becomes "UPI Facility" rather * than "Upi Facility". Anything not listed just gets title-cased, so an unknown key still renders @@ -76,6 +106,24 @@ const LABEL_ACRONYMS = new Set([ 'nef' ]); +/** + * Fields that identify one element of an array, tried in order. Keeping this as a list of names rather + * than per-entity rules means an unfamiliar collection still gets a readable heading if it happens to + * carry any of them. + */ +const ENTRY_LABEL_KEYS = [ + 'limitType', + 'name', + 'displayName', + 'chargeName', + 'title', + 'label', + 'type', + 'description', + 'code', + 'value' +]; + @Component({ selector: 'mifosx-view-checker-inbox', templateUrl: './view-checker-inbox.component.html', @@ -93,6 +141,30 @@ export class ViewCheckerInboxComponent { private router = inject(Router); private translateService = inject(TranslateService); private tasksService = inject(TasksService); + private systemService = inject(SystemService); + private productsService = inject(ProductsService); + private clientsService = inject(ClientsService); + private savingsService = inject(SavingsService); + private accountingService = inject(AccountingService); + private usersService = inject(UsersService); + private fixedDepositsService = inject(FixedDepositsService); + private recurringDepositsService = inject(RecurringDepositsService); + private organizationService = inject(OrganizationService); + + /** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */ + private get entityFetchServices(): EntityFetchServices { + return { + products: this.productsService, + clients: this.clientsService, + savings: this.savingsService, + system: this.systemService, + accounting: this.accountingService, + users: this.usersService, + fixedDeposits: this.fixedDepositsService, + recurringDeposits: this.recurringDepositsService, + organization: this.organizationService + }; + } /** Present only when opened as a popup from the Checker Inbox list; absent when reached by direct * route (a bookmarked or shared link). Both entry points stay supported — this only changes where * the detail comes from and how "done" is signalled: closing the dialog vs. navigating back. */ @@ -122,6 +194,17 @@ export class ViewCheckerInboxComponent { */ commandFields: CommandField[] = []; + /** UPDATE review: the fields this command actually moves, as OLD -> NEW. */ + changes: CommandChange[] = []; + /** True once the current entity was read and a real comparison is on screen. */ + showChanges = false; + /** True while the current entity is being fetched for comparison. */ + comparing = false; + /** True when the screen is describing a record that is about to be deleted. */ + deletingEntity = false; + /** CREATE review: submitted data bucketed into readable sections. */ + createSections: { title: string; fields: CommandField[] }[] = []; + /** camelCase / snake_case key -> readable label, e.g. `upiFacility` -> "UPI Facility". */ private labelFor(key: string): string { const spaced = String(key ?? '') @@ -156,6 +239,27 @@ export class ViewCheckerInboxComponent { * Recursively turns one key/value pair into a render node. Nothing is dropped or summarised - every * key in the payload produces a node, so the checker still sees the complete request. */ + /** + * Names one element of an array of objects. + * + * Numbering them "Account Facility Limits 1 ... 10" tells a checker nothing - the position in a JSON + * array is not a fact about the record. Most entries carry a field that identifies them (`limitType`, + * a charge's `name`, a chart's `description`), so that value is used as the heading and the ordinal + * is only a fallback for entries that have nothing identifying at all. + */ + private entryLabel(item: Record, listLabel: string, index: number): string { + for (const key of ENTRY_LABEL_KEYS) { + const candidate = item?.[key]; + if (typeof candidate === 'string' && candidate.trim() !== '') { + return candidate.trim(); + } + if (typeof candidate === 'number') { + return String(candidate); + } + } + return `${listLabel} ${index + 1}`; + } + private toField(label: string, value: any): CommandField { if (Array.isArray(value)) { return { @@ -163,7 +267,7 @@ export class ViewCheckerInboxComponent { kind: 'list', items: value.map((item, index) => item !== null && typeof item === 'object' - ? this.toField(`${label} ${index + 1}`, item) + ? this.toField(this.entryLabel(item, label, index), item) : { label: '', kind: 'scalar' as const, text: this.scalarText(item) } ) }; @@ -172,7 +276,9 @@ export class ViewCheckerInboxComponent { return { label, kind: 'object', - fields: Object.keys(value).map((key) => this.toField(this.labelFor(key), value[key])) + fields: Object.keys(value) + .filter((key) => !NOISE_KEYS.has(key)) + .map((key) => this.toField(this.labelFor(key), value[key])) }; } return { label, kind: 'scalar', text: this.scalarText(value) }; @@ -182,7 +288,9 @@ export class ViewCheckerInboxComponent { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return []; } - return Object.keys(payload).map((key) => this.toField(this.labelFor(key), payload[key])); + return Object.keys(payload) + .filter((key) => !NOISE_KEYS.has(key)) + .map((key) => this.toField(this.labelFor(key), payload[key])); } /** Checker Inbox Details Data */ @@ -238,6 +346,14 @@ export class ViewCheckerInboxComponent { private applyDetail(detail: any): void { this.checkerInboxDetail = detail; + // Angular reuses this component when only the route's id changes, so the previous request's review + // state has to be cleared explicitly — otherwise a DELETE opened after an UPDATE keeps showing the + // UPDATE's changes. + this.changes = []; + this.showChanges = false; + this.comparing = false; + this.deletingEntity = false; + this.createSections = []; // commandAsJson is absent on some entries (a DELETE carries no payload), and JSON.parse(undefined) // throws — which as a route only broke the page, but in a dialog would leave an empty shell open. try { @@ -247,6 +363,274 @@ export class ViewCheckerInboxComponent { } this.displayJSONData = !_.isEmpty(this.jsondata); this.commandFields = this.buildCommandFields(this.jsondata); + + // Role permissions get their own treatment: the payload is the role's whole permission map, so a + // field-by-field diff would be 1,100 rows rather than the handful that moved. + if (this.isRolePermissionsCommand(detail)) { + this.loadRolePermissionChanges(detail.resourceId, this.jsondata.permissions); + return; + } + + const action = (detail?.actionName ?? '').toUpperCase(); + if (action === 'UPDATE') { + this.loadUpdateChanges(detail); + } else if (action === 'DELETE') { + this.loadDeleteSummary(detail); + } else if (action === 'CREATE') { + this.createSections = this.buildCreateSections(this.jsondata); + if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') { + this.resolveGlAccountCreateLabels(detail, this.jsondata); + } + } + } + + /** + * UPDATE: read the entity as it stands today and show only what the command actually moves. + * + * Several screens submit their entire form regardless of what the maker touched - a fixed deposit + * account update posts 28 fields every time - so without this the checker cannot tell which one + * changed. If the entity cannot be read the submitted payload stays on screen, which is what every + * entity showed before. + */ + private loadUpdateChanges(detail: any): void { + const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices); + if (!request$) { + return; + } + this.comparing = true; + request$.subscribe({ + next: (current: any) => { + // A slow response for a record the user has already navigated away from must not overwrite the + // record now on screen. + if (!this.isCurrent(detail)) { + return; + } + this.comparing = false; + this.changes = diffCommandAgainstEntity(this.jsondata ?? {}, current ?? {}); + this.showChanges = true; + }, + error: () => { + this.comparing = false; + } + }); + } + + /** + * DELETE: the payload is empty for every entity, so the command alone tells the checker nothing. + * Reads the record instead and shows the few fields that identify it. + */ + private loadDeleteSummary(detail: any): void { + const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices); + if (!request$) { + return; + } + this.comparing = true; + request$.subscribe({ + next: (current: any) => { + if (!this.isCurrent(detail)) { + return; + } + this.comparing = false; + const keys = deleteSummaryKeys(detail?.entityName); + const fields = keys + .filter((key) => current?.[key] !== undefined && current?.[key] !== null && current?.[key] !== '') + .map((key) => ({ + label: humanizeKey(key), + kind: 'scalar' as const, + text: displayScalar(current[key]) + })); + if (fields.length > 0) { + this.commandFields = fields; + this.displayJSONData = true; + this.deletingEntity = true; + } + }, + error: () => { + this.comparing = false; + } + }); + } + + /** Whether `detail` is still the request on screen, i.e. an in-flight lookup is not stale. */ + private isCurrent(detail: any): boolean { + return this.checkerInboxDetail?.id === detail?.id; + } + + /** CREATE: nothing to compare against, so the submitted data is grouped rather than diffed. */ + private buildCreateSections(payload: any): { title: string; fields: CommandField[] }[] { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return []; + } + return groupCreateFields(payload).map((section) => ({ + title: section.title, + fields: section.keys.map((key) => this.toField(this.labelFor(key), payload[key])) + })); + } + + /** + * GL Account CREATE: `type`, `usage`, `parentId` and `tagId` arrive as raw numeric ids - meaningless + * to a checker without the chart-of-accounts template, which is the same lookup data the create-GL + * form itself already uses (`create-gl-account.component.ts`). Fetched once, purely for display: the + * stored command payload (`jsondata`) and the already-rendered raw sections are left as they are + * unless the lookup succeeds, so a slow or failed template call never blocks or breaks this screen. + */ + private resolveGlAccountCreateLabels(detail: any, payload: Record): void { + this.accountingService.getChartOfAccountsTemplate().subscribe({ + next: (template: any) => { + // A slow response for a request the checker has already navigated away from must not overwrite + // whatever is now on screen. + if (!this.isCurrent(detail)) { + return; + } + const resolved = { ...payload }; + const typeId = payload.type; + + const typeOption = (template?.accountTypeOptions ?? []).find((option: any) => option.id === typeId); + if (typeOption) { + resolved.type = this.translateService.instant(`labels.inputs.accounting.${typeOption.value}`); + } + + const usageOption = (template?.usageOptions ?? []).find((option: any) => option.id === payload.usage); + if (usageOption) { + resolved.usage = usageOption.value; + } + + const parentAccount = this.glHeaderAccountOptions(template, typeId).find( + (account: any) => account.id === payload.parentId + ); + if (parentAccount) { + resolved.parentId = parentAccount.glCode + ? `${parentAccount.name} (${parentAccount.glCode})` + : parentAccount.name; + } + + const tagOption = this.glTagOptions(template, typeId).find((tag: any) => tag.id === payload.tagId); + if (tagOption) { + resolved.tagId = tagOption.name; + } + + this.createSections = this.buildCreateSections(resolved); + }, + // Lookup failed - the raw ids already on screen from buildCreateSections() above stay as they are. + error: () => undefined + }); + } + + /** Header/parent account options are partitioned by GL account type, same as the create-GL form. */ + private glHeaderAccountOptions(template: any, typeId: number): any[] { + switch (typeId) { + case 1: + return template?.assetHeaderAccountOptions ?? []; + case 2: + return template?.liabilityHeaderAccountOptions ?? []; + case 3: + return template?.equityHeaderAccountOptions ?? []; + case 4: + return template?.incomeHeaderAccountOptions ?? []; + case 5: + return template?.expenseHeaderAccountOptions ?? []; + default: + return []; + } + } + + /** Allowed tag options are partitioned by GL account type, same as the create-GL form. */ + private glTagOptions(template: any, typeId: number): any[] { + switch (typeId) { + case 1: + return template?.allowedAssetsTagOptions ?? []; + case 2: + return template?.allowedLiabilitiesTagOptions ?? []; + case 3: + return template?.allowedEquityTagOptions ?? []; + case 4: + return template?.allowedIncomeTagOptions ?? []; + case 5: + return template?.allowedExpensesTagOptions ?? []; + default: + return []; + } + } + + /** + * A role permission update posts the role's *entire* permission map - over 1,100 entries, of which + * a handful are actually being changed. Rendering that as-is buries the change completely, so this + * screen fetches the role's current permissions and shows only the difference. + */ + private isRolePermissionsCommand(detail: any): boolean { + return ( + (detail?.entityName ?? '').toUpperCase() === 'ROLE' && + (detail?.actionName ?? '').toUpperCase() === 'PERMISSIONS' && + detail?.resourceId != null && + !!this.jsondata?.permissions + ); + } + + /** + * Replaces the flat permission dump with Granted / Revoked lists, by comparing the submitted map + * against what the role holds today. Anything the maker left untouched is summarised as a count + * rather than listed. + * + * If the role lookup fails the original payload stays on screen - a degraded but complete view is + * better than showing the checker nothing. + */ + private loadRolePermissionChanges(roleId: string | number, submitted: Record): void { + this.systemService.getRole(roleId).subscribe({ + next: (role: any) => { + const current = new Map( + (role?.permissionUsageData ?? []).map((p: any): [string, boolean] => [ + p.code, + !!p.selected + ]) + ); + + const granted: string[] = []; + const revoked: string[] = []; + let unchanged = 0; + + Object.keys(submitted).forEach((code) => { + const now = !!submitted[code]; + const before = current.get(code); + // A code missing from the role lookup has no "before" to compare against, so treat it as + // changed only when it is being switched on. + if (before === undefined) { + if (now) { + granted.push(code); + } + return; + } + if (now === before) { + unchanged++; + } else if (now) { + granted.push(code); + } else { + revoked.push(code); + } + }); + + this.commandFields = this.buildPermissionChangeFields(granted, revoked, unchanged); + }, + error: () => undefined + }); + } + + private buildPermissionChangeFields(granted: string[], revoked: string[], unchanged: number): CommandField[] { + const fields: CommandField[] = []; + const asList = (label: string, codes: string[]): CommandField => ({ + label, + kind: 'list', + items: codes.sort().map((code) => ({ label: '', kind: 'scalar' as const, text: this.labelFor(code) })) + }); + + fields.push(asList(`Permissions Granted (${granted.length})`, granted)); + fields.push(asList(`Permissions Revoked (${revoked.length})`, revoked)); + + if (granted.length === 0 && revoked.length === 0) { + fields.push({ label: 'Result', kind: 'scalar', text: 'No permission changes in this request.' }); + } + fields.push({ label: 'Unchanged Permissions', kind: 'scalar', text: `${unchanged} left as they were` }); + + return fields; } /** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a @@ -304,23 +688,4 @@ export class ViewCheckerInboxComponent { } }); } - - /** - * Delete Checker - */ - deleteChecker() { - const deleteCheckerDialogRef = this.dialog.open(ConfirmationDialogComponent, { - data: { - heading: this.translateService.instant('labels.heading.Delete Checker'), - dialogContext: this.translateService.instant('labels.dialogContext.Are you sure you want to delete checker') - } - }); - deleteCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => { - if (response.confirm) { - this.tasksService.deleteMakerChecker(this.checkerInboxDetail.id).subscribe((res: any) => { - this.finish(); - }); - } - }); - } } diff --git a/src/app/tds-reports/tds-interest-export.spec.ts b/src/app/tds-reports/tds-interest-export.spec.ts new file mode 100644 index 000000000..dbb9cbc9b --- /dev/null +++ b/src/app/tds-reports/tds-interest-export.spec.ts @@ -0,0 +1,164 @@ +/** + * @jest-environment-options {"customExportConditions": ["node", "node-addons"]} + */ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture'; +import { buildTdsInterestWorkbook } from './tds-interest-export'; +import { TdsDeductee, setTdsDeductees } from './tds-reports-data'; +import { tdsInterestRows, tdsInterestSummary } from './tds-reports.compute'; + +installTdsConfigFixture(); + +const FY = '2025-26'; +const Q = [ + '2025-06-30', + '2025-09-30', + '2025-12-31', + '2026-03-31' +]; + +const BOOK: TdsDeductee[] = [ + { + clientId: 1, + customerId: 'C1', + name: 'Ramesh', + pan: 'ABCPR1234K', + dob: '1980-05-01', + branchCode: 'Head Office', + declarations: {}, + accounts: [ + { + id: 11, + accountNo: 'FD11', + depositTypeCode: 'depositAccountType.fixedDeposit', + clientId: 1, + credits: Q.map((date) => ({ date, amount: 15_000 })), + deductions: [{ date: Q[3], amount: 6_000 }] + }, + { + id: 12, + accountNo: 'SB12', + depositTypeCode: 'depositAccountType.savingsDeposit', + clientId: 1, + credits: [{ date: Q[1], amount: 1_200 }], + deductions: [] + } + ] + }, + { + clientId: 4, + customerId: 'C4', + name: 'Kamala', + pan: 'ABCPK9012M', + dob: '1979-07-07', + branchCode: 'bhopal', + declarations: { [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } }, + accounts: [ + { + id: 41, + accountNo: 'FD41', + depositTypeCode: 'depositAccountType.fixedDeposit', + clientId: 4, + credits: Q.map((date) => ({ date, amount: 20_000 })), + deductions: [] + } + ] + } +]; + +function build() { + setTdsDeductees(BOOK); + const filter = { + fy: FY, + branchCode: '', + quarter: 0 as const, + band: '' as const, + search: '', + crossedOnly: false, + nearThresholdOnly: false + }; + const rows = tdsInterestRows(filter); + return buildTdsInterestWorkbook({ + fy: FY, + scopeLabel: 'All branches', + rules: { general: 50_000, senior: 100_000, rate: 10, rateNoPan: 20 }, + statement: 'Form 26Q', + rows, + declaredRows: rows.filter((r) => r.hasDeclaration), + summary: tdsInterestSummary(filter), + statusOf: () => ({ label: 'status' }) + }); +} + +describe('TDS register workbook', () => { + it('has the four sheets a reader expects, in order', () => { + expect(build().worksheets.map((w) => w.name)).toEqual([ + 'Summary', + 'Customers', + 'Accounts', + 'Form 121 given' + ]); + }); + + it('lists one row per customer, with the figures as numbers and a live total', () => { + const ws = build().getWorksheet('Customers')!; + expect(ws.getRow(3).getCell(2).value).toBe('Customer name'); + const at = [ + 4, + 5 + ].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!; + const ramesh = ws.getRow(at); + expect(ramesh.getCell(2).value).toBe('Ramesh'); + expect(ramesh.getCell(7).value).toBe(60_000); + expect(ramesh.getCell(7).numFmt).toContain('#,##0.00'); + expect(ramesh.getCell(11).value).toBe(6_000); + expect(ramesh.getCell(12).value).toBe(6_000); + expect(ramesh.getCell(13).value).toEqual({ formula: `K${at}-L${at}` }); + const total = ws.getRow(6); + expect(total.getCell(2).value).toBe('Total'); + expect(total.getCell(7).value).toEqual({ formula: 'SUM(G4:G5)' }); + }); + + it('lists every account behind the figures, saying which ones count', () => { + const ws = build().getWorksheet('Accounts')!; + const byAccount = new Map( + [ + 4, + 5, + 6 + ].map((n) => [ + ws.getRow(n).getCell(4).value, + ws.getRow(n) + ]) + ); + expect([...byAccount.keys()].sort()).toEqual([ + 'FD11', + 'FD41', + 'SB12' + ]); + expect(byAccount.get('SB12')!.getCell(6).value).toBe('No'); + expect(byAccount.get('FD11')!.getCell(6).value).toBe('Yes'); + expect(byAccount.get('FD11')!.getCell(10).value).toBeInstanceOf(Date); + }); + + it('puts the declarants on their own sheet with the TDS the form saved', () => { + const ws = build().getWorksheet('Form 121 given')!; + const kamala = ws.getRow(4); + expect(kamala.getCell(2).value).toBe('Kamala'); + expect(kamala.getCell(7).value).toBe('UIN0001'); + expect(kamala.getCell(10).value).toBe(40_000); + expect(kamala.getCell(11).value).toBe(4_000); + }); + + it('writes a file Excel can open', async () => { + const buffer = await build().xlsx.writeBuffer(); + expect(buffer.byteLength).toBeGreaterThan(5_000); + }); +}); diff --git a/src/app/tds-reports/tds-interest-export.ts b/src/app/tds-reports/tds-interest-export.ts new file mode 100644 index 000000000..f4f3c657b --- /dev/null +++ b/src/app/tds-reports/tds-interest-export.ts @@ -0,0 +1,453 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import * as ExcelJS from 'exceljs'; + +import { TdsAccountLine, TdsInterestRow, TdsInterestSummary } from './tds-reports.compute'; + +/** What the workbook needs from the screen, so it prints exactly what the user is looking at. */ +export interface TdsExportInput { + fy: string; + scopeLabel: string; + /** The financial-year rules, from Organization › TDS Configuration. */ + rules: { general: number; senior: number; rate: number; rateNoPan: number } | null; + /** The quarterly return the declarants' gross-paid interest is reported in. */ + statement: string; + rows: TdsInterestRow[]; + declaredRows: TdsInterestRow[]; + summary: TdsInterestSummary; + statusOf: (row: TdsInterestRow) => { label: string }; +} + +const MONEY = '"₹"#,##0.00'; +const DATE = 'dd-mmm-yyyy'; +const NAVY = 'FF1F4E79'; +const STRIPE = 'FFF3F6FA'; +const LINE = 'FFD0D7E2'; +const GREEN = 'FF15803D'; +const AMBER = 'FFB45309'; + +const ACCOUNT_TYPE: Record = { + FD: 'Fixed deposit', + RD: 'Recurring deposit', + SB: 'Savings', + OTHER: 'Other' +}; + +/** A column: its heading, width and, for figures, the cell format. */ +interface Col { + header: string; + width: number; + fmt?: string; + /** Totalled in the closing row. */ + sum?: boolean; +} + +/** + * The Deposit Interest & TDS register as a workbook a branch can read without the screen beside it: + * a one-page summary, one row per customer, one row per account, and the declarants on their own. + */ +export function buildTdsInterestWorkbook(input: TdsExportInput): ExcelJS.Workbook { + const wb = new ExcelJS.Workbook(); + wb.creator = 'CBS Nexora'; + wb.created = new Date(); + + summarySheet(wb, input); + customersSheet(wb, input); + accountsSheet(wb, input); + declarationsSheet(wb, input); + return wb; +} + +// ─────────────────────────────── Sheets ─────────────────────────────── + +function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const ws = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] }); + ws.columns = [ + { width: 46 }, + { width: 22 }]; + title(ws, `Deposit Interest & TDS — FY ${input.fy}`, 2); + ws.addRow([`Branch: ${input.scopeLabel}`]); + ws.addRow([ + `Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}` + ]); + ws.getRow(2).font = { color: { argb: 'FF555555' } }; + ws.getRow(3).font = { color: { argb: 'FF555555' } }; + ws.addRow([]); + + const s = input.summary; + section(ws, 'Key figures'); + const figures: [string, number, string?][] = [ + [ + 'Customers in this report', + s.deductees + ], + [ + ' of whom have an FD / RD being tracked', + s.trackedCustomers + ], + [ + 'FD / RD interest received this year', + s.applicableInterest, + MONEY + ], + [ + 'Savings interest (shown only — no TDS)', + s.exemptInterest, + MONEY + ], + [ + 'Customers over the TDS limit', + s.crossed + ], + [ + 'TDS due by the rules', + s.totalTds, + MONEY + ], + [ + 'TDS actually deducted from accounts', + s.actualTds, + MONEY + ], + [ + 'Difference (due − deducted) — should be 0', + s.totalTds - s.actualTds, + MONEY + ], + [ + 'Customers with Form 121 / 15G / 15H', + s.declarations + ], + [ + 'TDS not deducted because of those forms', + s.tdsSaved, + MONEY + ], + [ + 'Interest paid without TDS (report in the return)', + s.paidWithoutDeduction, + MONEY + ] + ]; + for (const [ + label, + value, + fmt + ] of figures) { + const r = ws.addRow([ + label, + value + ]); + r.getCell(2).numFmt = fmt ?? '#,##0'; + r.getCell(2).alignment = { horizontal: 'right' }; + boxRow(r, 2); + } + // The control figure stands out when it is not zero. + const diffRow = ws.getRow(ws.rowCount - 3); + if (Math.round(s.totalTds - s.actualTds) !== 0) { + diffRow.font = { bold: true, color: { argb: AMBER } }; + } + ws.addRow([]); + + if (input.rules) { + section(ws, 'Rules for this year'); + const rules: [string, string][] = [ + [ + 'TDS limit — below 60', + rupees(input.rules.general) + ], + [ + 'TDS limit — senior citizen (60+)', + rupees(input.rules.senior) + ], + [ + 'Rate with PAN', + `${input.rules.rate}%` + ], + [ + 'Rate without PAN', + `${input.rules.rateNoPan}%` + ] + ]; + for (const [ + label, + value + ] of rules) { + const r = ws.addRow([ + label, + value + ]); + r.getCell(2).alignment = { horizontal: 'right' }; + boxRow(r, 2); + } + ws.addRow([]); + } + + section(ws, 'How to read this workbook'); + const notes = [ + 'Only interest from fixed and recurring deposits counts toward the limit — on the day it reaches savings, or when paid out at closure.', + "A savings account's own interest is shown for information and never attracts TDS.", + "Once a customer's interest for the year crosses the limit, TDS is due on the whole year's interest, not only the excess.", + 'A Form 121 / 15G / 15H stops TDS on interest received after the form was accepted.', + 'Sheet "Customers": one row per customer. Sheet "Accounts": every account behind those figures.', + 'Sheet "Form 121 given": customers with a declaration, and the TDS the form saved them.' + ]; + for (const n of notes) { + const r = ws.addRow([`• ${n}`]); + ws.mergeCells(r.number, 1, r.number, 2); + r.alignment = { wrapText: true, vertical: 'top' }; + r.height = 30; + } +} + +function customersSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const cols: Col[] = [ + { header: 'S.No', width: 6 }, + { header: 'Customer name', width: 26 }, + { header: 'Customer ID', width: 13 }, + { header: 'PAN', width: 13 }, + { header: 'Branch', width: 14 }, + { header: 'Age group', width: 12 }, + { header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true }, + { header: 'TDS limit (₹)', width: 13, fmt: MONEY }, + { header: 'Over the limit?', width: 10 }, + { header: 'Rate', width: 7 }, + { header: 'TDS due (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'Difference (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'Form 121 given?', width: 10 }, + { header: 'Status', width: 42 } + ]; + const ws = table(wb, 'Customers', `Customers — FY ${input.fy} · ${input.scopeLabel}`, cols); + + input.rows.forEach((r, i) => { + const line = ws.addRow([ + i + 1, + r.deductee.name, + r.deductee.customerId, + r.deductee.pan || 'Not on record', + r.branchCode, + r.band === 'SENIOR' ? 'Senior (60+)' : 'Below 60', + r.tdsApplicableInterest, + r.threshold, + r.crossed ? 'Yes' : 'No', + `${Math.round(r.rateApplied * 100)}%`, + r.schedule.totalTds, + r.actualTds, + null, + r.hasDeclaration ? 'Yes' : 'No', + input.statusOf(r).label + ]); + // Due − deducted as a formula, so an edited figure keeps the check honest. + line.getCell(13).value = { formula: `K${line.number}-L${line.number}` }; + styleDataRow(line, cols, i); + if (!r.deductee.pan) { + line.getCell(4).font = { color: { argb: AMBER } }; + } + if (r.crossed) { + line.getCell(9).font = { bold: true, color: { argb: AMBER } }; + } + if (Math.round(r.schedule.totalTds - r.actualTds) !== 0) { + line.getCell(13).font = { bold: true, color: { argb: AMBER } }; + } + }); + totals(ws, cols, input.rows.length); +} + +function accountsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const cols: Col[] = [ + { header: 'S.No', width: 6 }, + { header: 'Customer name', width: 26 }, + { header: 'Customer ID', width: 13 }, + { header: 'Account no.', width: 19 }, + { header: 'Account type', width: 17 }, + { header: 'Counted for TDS?', width: 10 }, + { header: 'Why', width: 46 }, + { header: 'Interest this year (₹)', width: 15, fmt: MONEY, sum: true }, + { header: 'Credits', width: 8 }, + { header: 'Last credited', width: 13, fmt: DATE }, + { header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'Account held by', width: 22 } + ]; + const ws = table(wb, 'Accounts', `Accounts — FY ${input.fy} · ${input.scopeLabel}`, cols); + + let n = 0; + for (const r of input.rows) { + for (const a of r.accounts) { + const line = ws.addRow([ + n + 1, + r.deductee.name, + r.deductee.customerId, + a.accountNo, + ACCOUNT_TYPE[a.type], + a.tracked ? 'Yes' : 'No', + a.reason, + a.interest, + a.postings, + a.lastPostedOn ? isoToDate(a.lastPostedOn) : '—', + a.tdsDeducted, + a.holderName ?? 'Same customer' + ]); + styleDataRow(line, cols, n); + if (a.tracked) { + line.getCell(6).font = { bold: true, color: { argb: GREEN } }; + } + n++; + } + } + totals(ws, cols, n); +} + +function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const cols: Col[] = [ + { header: 'S.No', width: 6 }, + { header: 'Customer name', width: 26 }, + { header: 'Customer ID', width: 13 }, + { header: 'PAN', width: 13 }, + { header: 'Branch', width: 14 }, + { header: 'Form', width: 10 }, + { header: 'UIN', width: 18 }, + { header: 'Accepted on', width: 13, fmt: DATE }, + { header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true }, + { header: `Paid without TDS — report in ${input.statement} (₹)`, width: 20, fmt: MONEY, sum: true }, + { header: 'TDS not deducted because of the form (₹)', width: 18, fmt: MONEY, sum: true }, + { header: 'TDS deducted before the form (₹)', width: 16, fmt: MONEY, sum: true } + ]; + const ws = table( + wb, + 'Form 121 given', + `Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`, + cols + ); + + input.declaredRows.forEach((r, i) => { + const line = ws.addRow([ + i + 1, + r.deductee.name, + r.deductee.customerId, + r.deductee.pan || 'Not on record', + r.branchCode, + `Form ${r.form}`, + r.declarationUin ?? 'Not allotted', + r.declarationAcceptedOn ? isoToDate(r.declarationAcceptedOn) : '—', + r.tdsApplicableInterest, + r.paidWithoutDeduction, + r.tdsSaved, + r.actualTds + ]); + styleDataRow(line, cols, i); + if (!r.declarationUin) { + line.getCell(7).font = { color: { argb: AMBER } }; + } + }); + if (input.declaredRows.length === 0) { + const r = ws.addRow([ + '', + 'No customer has a declaration on record for this year.' + ]); + r.font = { italic: true, color: { argb: 'FF777777' } }; + } else { + totals(ws, cols, input.declaredRows.length); + } +} + +// ─────────────────────────────── Styling ─────────────────────────────── + +/** A sheet with a title, a styled header row, frozen headings and a filter. */ +function table(wb: ExcelJS.Workbook, name: string, heading: string, cols: Col[]): ExcelJS.Worksheet { + const ws = wb.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 3, showGridLines: false }] }); + ws.columns = cols.map((c) => ({ width: c.width })); + title(ws, heading, cols.length); + ws.addRow([]); + const head = ws.addRow(cols.map((c) => c.header)); + head.height = 34; + head.eachCell((cell) => { + cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + cell.border = border(); + }); + ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: cols.length } }; + return ws; +} + +function title(ws: ExcelJS.Worksheet, text: string, span: number): void { + const r = ws.addRow([text]); + ws.mergeCells(r.number, 1, r.number, span); + r.font = { bold: true, size: 14, color: { argb: NAVY } }; + r.height = 24; +} + +function section(ws: ExcelJS.Worksheet, text: string): void { + const r = ws.addRow([text]); + r.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + r.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } }; + r.getCell(2).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } }; +} + +function styleDataRow(row: ExcelJS.Row, cols: Col[], index: number): void { + cols.forEach((c, i) => { + const cell = row.getCell(i + 1); + cell.border = border(); + cell.alignment = { vertical: 'middle', wrapText: i === cols.length - 1 || c.width > 30 }; + if (c.fmt) { + cell.numFmt = c.fmt; + cell.alignment = { ...cell.alignment, horizontal: 'right' }; + } + if (index % 2 === 1) { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: STRIPE } }; + } + }); +} + +/** A closing row that totals the money columns with live SUM formulas. */ +function totals(ws: ExcelJS.Worksheet, cols: Col[], count: number): void { + if (count === 0) { + return; + } + const first = 4; + const last = first + count - 1; + const r = ws.addRow(cols.map((c, i) => (i === 1 ? 'Total' : null))); + cols.forEach((c, i) => { + const cell = r.getCell(i + 1); + if (c.sum) { + const letter = ws.getColumn(i + 1).letter; + cell.value = { formula: `SUM(${letter}${first}:${letter}${last})` }; + cell.numFmt = c.fmt ?? '#,##0'; + cell.alignment = { horizontal: 'right' }; + } + cell.font = { bold: true }; + cell.border = { top: { style: 'medium', color: { argb: NAVY } }, bottom: { style: 'thin', color: { argb: LINE } } }; + }); +} + +function boxRow(row: ExcelJS.Row, span: number): void { + for (let i = 1; i <= span; i++) { + row.getCell(i).border = border(); + } +} + +function border(): Partial { + const side = { style: 'thin' as const, color: { argb: LINE } }; + return { top: side, left: side, bottom: side, right: side }; +} + +function rupees(n: number): string { + return `₹${Math.round(n).toLocaleString('en-IN')}`; +} + +/** '2026-10-05' as a real date, so Excel sorts and filters it as one. */ +function isoToDate(iso: string): Date { + const [ + y, + m, + d + ] = iso.slice(0, 10).split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d)); +} diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html index 27cd9f12e..70117543e 100644 --- a/src/app/tds-reports/tds-interest-report.component.html +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -13,68 +13,132 @@

Deposit Interest & TDS

- Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} · - {{ scopeLabel }} + Interest each customer earned this year, and whether TDS has to be deducted on it · + Showing: {{ scopeLabel }}
- +
+ + +
+ @if (loadError) { +
+ +
{{ loadError }}
+
+ } + + + @if (showHelp) { +
+
How to read this page
+
    +
  1. + Every customer with an account is listed. Each account is either + Tracked — its interest counts toward the TDS limit — or + Not tracked. Open a row () to see which + account is which, and why. +
  2. +
  3. + Only the interest from + fixed and recurring deposits that reaches the customer's savings account + is tracked — counted on the day it arrives. The savings account's own interest is shown for information but + never attracts TDS. Interest still inside an FD/RD counts when it is paid out at maturity or closure — to + savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is + not taxed. +
  4. + @if (rules) { +
  5. + A customer's tracked interest for the whole year, across every branch, is checked against the limit: + ₹{{ rules.general | number: '1.0-0' }}, or ₹{{ rules.senior | number: '1.0-0' }} for a senior citizen (60+). +
  6. +
  7. + Once over the limit, TDS is {{ rules.rate | number: '1.0-2' }}% of the + whole year's interest — {{ rules.rateNoPan | number: '1.0-2' }}% if no PAN is on + record. +
  8. + } +
  9. + A customer who has submitted Form 15G/15H is not deducted on interest received after the form was accepted. +
  10. +
  11. + TDS is debited from the savings account at the moment the FD/RD interest that takes the + customer over the limit arrives, and credited to the TDS payable account. The FD/RD itself is never debited. + The TDS column shows what is due by these rules and what was actually deducted — the two + should match. +
  12. +
+
+ } +
{{ summary.deductees | number }}
-
Deductees
+
Customers · {{ summary.trackedCustomers | number }} with an FD/RD being tracked
₹{{ summary.applicableInterest | number: '1.0-0' }}
-
Interest on FD / RD
+
FD / RD interest received in savings — counts toward the limit
₹{{ summary.exemptInterest | number: '1.0-0' }}
-
Savings interest — outside the charge
-
-
-
₹{{ summary.totalTds | number: '1.0-0' }}
-
TDS payable
+
Savings interest — shown only, no TDS
{{ summary.crossed | number }}
-
Crossed the threshold
+
Customers over the limit
- -
-
₹{{ summary.tdsSaved | number: '1.0-0' }}
-
Saved by {{ summary.declarations | number }} declarations
+
+
₹{{ summary.totalTds | number: '1.0-0' }}
+
TDS due by the rules
- @if (summary.noPan > 0) { -
-
{{ summary.noPan | number }}
-
No PAN — deducted at 20%
+
+
₹{{ summary.actualTds | number: '1.0-0' }}
+
+ Actually deducted from accounts + @if (summary.actualTds !== summary.totalTds) { + — ₹{{ summary.totalTds - summary.actualTds | number: '1.0-0' }} apart + }
- } +
- -
-
Quarter by quarter
-
- @for (q of summary.byQuarter; track q.quarter) { -
-
{{ quarterLabel[q.quarter] }}
-
₹{{ q.interest | number: '1.0-0' }}
-
TDS ₹{{ q.tds | number: '1.0-0' }}
-
+ + @if (summary.nearThreshold || summary.declarations || summary.noPan) { +
+ @if (summary.nearThreshold) { + + } + @if (summary.declarations) { + + {{ summary.declarations | number }} with 15G/15H — ₹{{ summary.tdsSaved | number: '1.0-0' }} not deducted + + } + @if (summary.noPan) { + + {{ summary.noPan | number }} FD/RD customers without PAN — higher rate applies + }
-
+ }
@@ -92,8 +156,8 @@ Branch @@ -110,32 +174,32 @@ @if (hasFilters) { @@ -143,7 +207,7 @@ }
- +
+
+ @if (selectedTab === 'declared') { +
+ +
+ Customers who gave a declaration for FY {{ filter.fy }}. TDS not deducted is the tax that would + have been taken without the form; Paid without TDS is the interest received after the form was + accepted — it must still be reported in the {{ statute.statement }} return. Interest received before the form + stays taxable, and any TDS taken on it is shown as deducted before the form. +
+
+ } +
- + - - - - - - - - - - + @if (selectedTab === 'declared') { + + + + + + } @else { + + + + + + } @for (row of pagedRows; track trackRow($index, row)) { - + - - - @for (q of quarters; track q) { - + @if (selectedTab === 'declared') { + + + } @else { + + } - - @@ -257,89 +363,154 @@ @if (isExpanded(row)) { - } } @if (visibleRows.length === 0) { - } @@ -352,8 +523,7 @@
Showing {{ rangeStart | number }}–{{ rangeEnd | number }} of - {{ visibleRows.length | number }} - {{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }} + {{ visibleRows.length | number }} customers
-
- - -
+ @if (isHeadOffice) { +
+ + + +
+ } - - @if (reconciled) { -
- + @if (!isHeadOffice) { +
+
- Reconciled. - ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ - summary.totalDeposited | number: '1.0-0' - }} - deposited, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date. + This report is for Head Office. TDS is paid to the Government by Head Office for the whole + bank, under one TAN. Your branch's customers and the TDS deducted from them are on + Reports › Deposit Interest & TDS.
} @else { -
- -
- Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted. - Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the - return. + @if (loadError) { +
+ +
{{ loadError }}
-
- } + } - @if (summary.overdueChallans > 0) { -
- -
- {{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue. - Interest runs at 1.5% for every month or part of a month until the tax reaches the Government. + + @if (showForm) { +
+
Record a challan — FY {{ fy }}
+
+ + + + + + + +
+
+ Saving posts Dr TDS payable / Cr the account paid from at Head Office, dated the day it was + paid. +
+ @if (formError) { +
+ +
{{ formError }}
+
+ } +
+ + +
-
- } + } - -
-
-
₹{{ summary.totalDeducted | number: '1.0-0' }}
-
Total TDS deducted
-
-
-
₹{{ summary.totalDeposited | number: '1.0-0' }}
-
Deposited with Government
-
-
-
₹{{ summary.pendingDeposit | number: '1.0-0' }}
-
Within due date
-
-
-
₹{{ totalDefaultInterest | number: '1.0-0' }}
-
Interest on late deposit
-
-
-
{{ summary.deductees | number }}
-
Deductees in the return
-
-
- - -
-
Part A — challan-wise remittance
-
-
Customer · PANCustomer BranchForm · BandQ1Q2Q3Q4Year totalThresholdCrossedTDSSavedForm · UIN · accepted onFD / RD interest received · paid without TDSLimitStatusTDS not deducted · deducted before the formAccountsFD / RD interest received this yearLimitStatusTDS
{{ row.deductee.name }}
- {{ row.deductee.customerId }} · + ID {{ row.deductee.customerId }} · @if (row.panOnRecord) { - {{ row.deductee.pan }} + PAN {{ row.deductee.pan }} } @else { no PAN } + @if (row.band === 'SENIOR') { + Senior 60+ + }
{{ branchName(row.branchCode) }} - - {{ row.form === '121' ? '121' : row.form }} - -
{{ row.band === 'SENIOR' ? 'Senior' : 'General' }}
- @if (row.hasDeclaration) { -
{{ row.declarationUin }}
- } -
- @if (row.quarters[q]) { - ₹{{ row.quarters[q] | number: '1.0-0' }} - } @else { - — + {{ row.branchCode || '—' }} + Form {{ row.form }} +
{{ row.declarationUin || 'no UIN yet' }}
+
accepted {{ row.declarationAcceptedOn | date: 'd MMM y' }}
+
+ ₹{{ row.tdsApplicableInterest | number: '1.0-0' }} +
₹{{ row.paidWithoutDeduction | number: '1.0-0' }} paid without TDS
+
+ @if (row.trackedAccounts) { + {{ row.trackedAccounts }} tracked + } + @if (untrackedOf(row).length) { + {{ untrackedOf(row).length }} not tracked + } + + ₹{{ row.tdsApplicableInterest | number: '1.0-0' }} + @if (row.exemptInterest) { +
+ ₹{{ row.exemptInterest | number: '1.0-0' }} savings (no TDS)
}
- ₹{{ row.tdsApplicableInterest | number: '1.0-0' }} - @if (row.exemptInterest) { -
+₹{{ row.exemptInterest | number: '1.0-0' }} SB (exempt)
- } -
₹{{ row.threshold | number: '1.0-0' }} - @if (row.crossedInQuarter) { - Q{{ row.crossedInQuarter }} - } @else if (row.nearThreshold) { - ₹{{ row.headroom | number: '1.0-0' }} left - } @else { - — - } + + {{ statusOf(row).label }} + - @if (row.schedule.totalTds) { - ₹{{ row.schedule.totalTds | number: '1.0-0' }} -
@ {{ row.rateApplied * 100 | number: '1.0-0' }}%
- } @else { - — - } -
- @if (row.tdsSaved) { + @if (selectedTab === 'declared') { ₹{{ row.tdsSaved | number: '1.0-0' }} +
not deducted
+ @if (row.actualTds || row.schedule.totalTds) { +
₹{{ row.actualTds | number: '1.0-0' }} deducted before the form
+ } + } @else if (row.schedule.totalTds || row.actualTds) { + ₹{{ row.schedule.totalTds | number: '1.0-0' }} +
due @ {{ row.rateApplied * 100 | number: '1.0-0' }}%
+
+ ₹{{ row.actualTds | number: '1.0-0' }} deducted +
+ } @else if (row.tdsSaved) { + ₹{{ row.tdsSaved | number: '1.0-0' }} saved } @else { — }
-
- @for (note of row.schedule.notes; track note) { -
- -
{{ note }}
-
- } - @if (row.jointHeldInterest) { -
- -
- ₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest sits on deposits where this customer - is a joint holder but not the first-named one. Tax there is deducted against the first-named - holder, so it is excluded from the figures above. -
-
- } - +
+ +
Accounts of {{ row.deductee.name }}
- - - - - - + + + + + + + - @for (line of row.schedule.lines; track line.quarter) { - - + - - - + + + + + - }
QuarterInterestCumulativeLiability to dateTDS this quarterReturn dueAccount no.TypeTracked for TDS?Interest this year (FD/RD: received in savings)CreditsLast creditedTDS deducted
- {{ line.label }} - @if (line.thresholdCrossedHere) { - crossed here - } - @if (line.declarationActive) { - Form 121 active + @for (a of row.accounts; track a.accountNo) { +
+ {{ a.accountNo }} + @if (a.holderName) { +
held by {{ a.holderName }}
}
₹{{ line.interest | number: '1.0-0' }}₹{{ line.cumInterest | number: '1.0-0' }}₹{{ line.cumLiability | number: '1.0-0' }}{{ accountTypeLabel[a.type] }} + @if (a.tracked) { + Yes + } @else { + No + } + {{ a.reason }} + ₹{{ a.interest | number: '1.2-2' }}{{ a.postings }} + {{ a.lastPostedOn ? (a.lastPostedOn | date: 'd MMM y') : 'Nothing received yet' }} + - @if (line.tds !== 0) { - ₹{{ line.tds | number: '1.0-0' }} + @if (a.tdsDeducted) { + ₹{{ a.tdsDeducted | number: '1.2-2' }} } @else { — } {{ line.dueDate }}
-
- Settlement: {{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }} (set by - the deposit product) - @if (row.declarationAcceptedOn) { - Declaration accepted: {{ row.declarationAcceptedOn | date: 'd MMM y' }} + + @if (row.trackedAccounts) { +
How the TDS is worked out, quarter by quarter
+ @for (note of row.schedule.notes; track note) { +
+ +
{{ note }}
+
+ } + @if (row.schedule.lines.length) { + + + + + + + + + + + + @for (line of row.schedule.lines; track line.quarter) { + + + + + + + + } + +
QuarterInterest in quarterTotal so farTDS this quarterReturn due
+ {{ line.label }} + @if (line.thresholdCrossedHere) { + limit crossed here + } + @if (line.declarationActive) { + 15G/15H active + } + ₹{{ line.interest | number: '1.0-0' }} + ₹{{ line.cumInterest | number: '1.0-0' }} + of ₹{{ row.threshold | number: '1.0-0' }} + + @if (line.tds !== 0) { + ₹{{ line.tds | number: '1.0-0' }} + } @else { + — + } + {{ line.dueDate }}
+ } @else { +
No interest has been posted on the tracked deposits this year yet.
} @if (row.paidWithoutDeduction) { - - Paid without deduction: - ₹{{ row.paidWithoutDeduction | number: '1.0-0' }} — reportable in - {{ statute.statement }} - +
+ + Paid without deduction because of 15G/15H: + ₹{{ row.paidWithoutDeduction | number: '1.0-0' }} — still reported in + {{ statute.statement }} + +
} -
+ } @else { +
+ +
+ This customer has no fixed or recurring deposit, so nothing here counts toward TDS. Savings + interest is shown for information only. +
+
+ } + + @if (row.jointHeldInterest) { +
+ +
+ ₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest is on joint deposits where this + customer is not the first-named holder, so it is counted under that holder instead. +
+
+ }
- No customers in this section match the filters. + + + @if (loading) { + Loading… + } @else if (selectedTab === 'paying') { + No customer needs TDS deducted for these filters. Check the "No TDS" tab. + } @else if (selectedTab === 'declared') { + No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's Form + 121 tab. + } @else { + No customers match these filters. + }
- - - - - - - - - - - - - - - @for (row of rows; track trackRow($index, row)) { - - - - - - - - - - - - } - @if (rows.length === 0) { - - - - } - -
QuarterDeduction monthAmountDue dateDeposited onChallan · BSRStatusLate-deposit interest{{ statute.statement }} due
- {{ quarterLabel[row.quarter] }} - {{ row.month }} - ₹{{ row.amount | number: '1.0-0' }} - {{ row.dueDate | date: 'd MMM y' }} - @if (row.depositedOn) { - {{ row.depositedOn | date: 'd MMM y' }} - } @else { - — - } - - @if (row.challanNo) { - {{ row.challanNo }} -
BSR {{ row.bsrCode }}
- } @else { - — - } -
- @if (row.overdue) { - Overdue - } @else if (row.late) { - Late by {{ row.delayMonths }}m - } @else if (row.depositedOn) { - On time - } @else { - Within due date - } - - @if (row.defaultInterest) { - ₹{{ row.defaultInterest | number: '1.0-0' }} -
1.5% × {{ row.delayMonths }}
- } @else { - — - } -
{{ row.returnDueDate }}
- No tax was deducted in this financial year. -
-
-
- Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April. -
-
- - -
-
- Part B — {{ statute.statement }} year-end position -
-
-
- Total interest paid on time deposits - ₹{{ summary.totalInterestPaid | number: '1.0-0' }} -
-
- with tax deducted - ₹{{ summary.interestWithDeduction | number: '1.0-0' }} -
-
- - without deduction — declaration on record - mandatory disclosure in {{ statute.statement }} - - ₹{{ summary.interestWithoutDeduction | number: '1.0-0' }} -
- -
- TDS at standard rate — PAN on record - ₹{{ summary.tdsAtStandardRate | number: '1.0-0' }} -
-
- TDS at higher rate — no PAN on record - ₹{{ summary.tdsAtHigherRate | number: '1.0-0' }} -
- -
- Total TDS deducted - ₹{{ summary.totalDeducted | number: '1.0-0' }} -
-
- Deposited with Government - ₹{{ summary.totalDeposited | number: '1.0-0' }} -
-
- Still within due date - ₹{{ summary.pendingDeposit | number: '1.0-0' }} -
-
- Difference — must be zero - ₹{{ summary.difference | number: '1.0-0' }} -
- - @if (summary.defaultInterest > 0) { -
- Interest payable by the bank for late deposit - ₹{{ summary.defaultInterest | number: '1.0-0' }} + @if (summary) { + + @if (reconciled) { +
+ +
+ Reconciled. + ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ + summary.totalDeposited | number: '1.0-0' + }} + paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date. +
+
+ } @else { +
+ +
+ Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted. + Tax deducted must equal tax paid plus tax still within its due date. + @if (summary.overdueAmount > 0) { + ₹{{ summary.overdueAmount | number: '1.0-0' }} is past its due date and not yet paid. + } +
} + + @if (summary.calculatedTds !== summary.totalDeducted) { +
+ +
+ By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but ₹{{ + summary.totalDeducted | number: '1.0-0' + }} + was actually deducted from accounts. Open Deposit Interest & TDS to see which customers differ. +
+
+ } + + +
+
+
₹{{ summary.totalDeducted | number: '1.0-0' }}
+
TDS deducted from accounts
+
+
+
₹{{ summary.totalDeposited | number: '1.0-0' }}
+
Paid to Government
+
+
+
₹{{ summary.pendingDeposit | number: '1.0-0' }}
+
To pay — still within due date
+
+
+
₹{{ summary.overdueAmount | number: '1.0-0' }}
+
Overdue — pay now
+
+
+
₹{{ summary.defaultInterest | number: '1.0-0' }}
+
Interest for paying late
+
+
+ } + + +
+
Part A — month by month
+
+ + + + + + + + + + + + + + + + + @for (row of rows; track trackRow($index, row)) { + + + + + + + + + + + + + } + @if (rows.length === 0) { + + + + } + +
QuarterDeduction monthDeductedPay byChallansPaidStill to payStatusLate interest
+ {{ quarterLabel[row.quarter] }} + + {{ row.month + '-01' | date: 'MMM y' }} +
{{ row.postings }} deduction{{ row.postings === 1 ? '' : 's' }}
+
+ ₹{{ row.deducted | number: '1.0-2' }} + {{ row.dueDate | date: 'd MMM y' }} + @for (c of row.challans; track c.id) { +
+ {{ c.challanNo }} · BSR {{ c.bsrCode }} · {{ c.depositedOn | date: 'd MMM y' }} · ₹{{ + c.amount | number: '1.0-2' + }} + +
+ } @empty { + — + } +
₹{{ row.deposited | number: '1.0-2' }} + @if (row.outstanding) { + ₹{{ row.outstanding | number: '1.0-2' }} + } @else if (row.excess) { + ₹{{ row.excess | number: '1.0-2' }} paid extra + } @else { + — + } + + @if (row.overdue) { + Overdue + } @else if (row.late) { + Paid late by {{ row.delayMonths }}m + } @else if (row.outstanding) { + Within due date + } @else { + Paid on time + } + + @if (row.defaultInterest) { + ₹{{ row.defaultInterest | number: '1.0-0' }} + } @else { + — + } + + @if (row.outstanding) { + + } +
+ + {{ loading ? 'Loading…' : 'No TDS was deducted from any account in this financial year.' }} +
+
+
+ Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April. + Interest runs at 1.5% for every month or part of a month it is late. +
-
- The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the - quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of - declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current - specification before generating the file. -
-
+ + + @if (summary) { +
+
+ Part B — {{ statute.statement }} year-end position +
+
+
+ Deductees (customers with an FD/RD) + {{ summary.deductees | number }} +
+
+ Total interest paid on time deposits + ₹{{ summary.totalInterestPaid | number: '1.0-0' }} +
+
+ with tax deducted + ₹{{ summary.interestWithDeduction | number: '1.0-0' }} +
+
+ + without deduction — declaration on record + mandatory disclosure in {{ statute.statement }} + + ₹{{ summary.interestWithoutDeduction | number: '1.0-0' }} +
+ +
+ TDS at standard rate — PAN on record + ₹{{ summary.tdsAtStandardRate | number: '1.0-0' }} +
+
+ TDS at higher rate — no PAN on record + ₹{{ summary.tdsAtHigherRate | number: '1.0-0' }} +
+ +
+ Total TDS deducted + ₹{{ summary.totalDeducted | number: '1.0-0' }} +
+
+ Paid to Government + ₹{{ summary.totalDeposited | number: '1.0-0' }} +
+
+ Still within due date + ₹{{ summary.pendingDeposit | number: '1.0-0' }} +
+
+ Difference — must be zero + ₹{{ summary.difference | number: '1.0-0' }} +
+ + @if (summary.defaultInterest > 0) { +
+ Interest payable by the bank for late deposit + ₹{{ summary.defaultInterest | number: '1.0-0' }} +
+ } +
+
+ The "without deduction" line is not optional — interest paid gross against a declaration must be reported in + the quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement + of declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current + specification before generating the file. +
+
+ } + }
diff --git a/src/app/tds-reports/tds-remittance-report.component.ts b/src/app/tds-reports/tds-remittance-report.component.ts index 5a1b9ce66..ee29ee6b5 100644 --- a/src/app/tds-reports/tds-remittance-report.component.ts +++ b/src/app/tds-reports/tds-remittance-report.component.ts @@ -6,17 +6,25 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import * as ExcelJS from 'exceljs'; +import { forkJoin } from 'rxjs'; import { BranchScopeService } from '../core/branch-scope.service'; import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT, TDS_FYS } from './tds-reports-data'; +import { TdsChallan, TdsDeductedMonth, setTdsDeductees, tdsFys } from './tds-reports-data'; +import { NewTdsChallan, TdsReportsService } from './tds-reports.service'; import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute'; +/** + * Tax deducted against tax remitted, month by month, for Head Office. + * + * The deducted side is the withhold-tax postings on deposits; the remitted side is the challans + * entered here. Both are records — nothing on this page is worked out from a rate. + */ @Component({ selector: 'mifosx-tds-remittance-report', templateUrl: './tds-remittance-report.component.html', @@ -27,21 +35,83 @@ import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } fro FormsModule ] }) -export class TdsRemittanceReportComponent { +export class TdsRemittanceReportComponent implements OnInit { private scope = inject(BranchScopeService); + private tdsService = inject(TdsReportsService); - readonly financialYears = TDS_FYS; + /** Filled by the route resolver from Organization › TDS Configuration. */ + readonly financialYears = tdsFys(); readonly quarterLabel = QUARTER_LABEL; - readonly asAt = TDS_AS_AT; - fy = TDS_FYS[0]; + fy = tdsFys()[0] ?? ''; exporting = false; + loading = false; + loadError = ''; + + /** The business date the platform is on — what "overdue" is judged against. */ + asAt = ''; + private deducted: TdsDeductedMonth[] = []; + challans: TdsChallan[] = []; + rows: TdsRemittanceRow[] = []; + summary: Tds26qSummary | null = null; + + paidFromOptions: { id: number; glCode: string; name: string }[] = []; + showForm = false; + saving = false; + formError = ''; + form: NewTdsChallan = this.blankForm(); + + /** Challans are remitted by Head Office under the bank's one TAN; a branch has nothing to enter here. */ + get isHeadOffice(): boolean { + return this.scope.isHeadOffice; + } + + ngOnInit(): void { + if (!this.isHeadOffice || !this.fy) { + return; + } + this.load(); + this.tdsService.getChallanTemplate().subscribe({ + next: (t) => (this.paidFromOptions = t?.paidFromOptions || []), + error: () => (this.paidFromOptions = []) + }); + } + + load(): void { + this.loading = true; + this.loadError = ''; + const openingYear = Number(this.fy.slice(0, 4)); + forkJoin({ + deducted: this.tdsService.getDeducted(this.fy), + challans: this.tdsService.getChallans(this.fy), + // Part B summarises the year per customer, so the interest register is loaded too. + deductees: this.tdsService.getDepositInterest(openingYear) + }).subscribe({ + next: ({ deducted, challans, deductees }) => { + this.loading = false; + this.asAt = deducted.asAt; + this.deducted = deducted.months; + this.challans = challans; + setTdsDeductees(deductees); + this.recompute(); + }, + error: (err: any) => { + this.loading = false; + this.loadError = err?.error?.defaultUserMessage || 'Could not load the remittance position.'; + } + }); + } + + private recompute(): void { + this.rows = tdsRemittanceRows(this.fy, this.deducted, this.challans, this.asAt); + this.summary = tds26qSummary(this.fy, this.rows, this.asAt); + } + + onYearChanged(): void { + this.showForm = false; + this.load(); + } - /** - * Provision and return references for the selected year. The quarterly return - * became Form 140 under the 2025 Act, so the labels are read from the config - * rather than written into the template. - */ get statute(): { section: string; statement: string; frequency: string } { const cfg = FY_TAX_CONFIG[this.fy]; return { @@ -51,53 +121,95 @@ export class TdsRemittanceReportComponent { }; } - get scopeLabel(): string { - return this.scope.isHeadOffice ? 'Bank as a whole' : this.scope.officeName; - } - - get rows(): TdsRemittanceRow[] { - return tdsRemittanceRows(this.fy, this.asAt); - } - - get summary(): Tds26qSummary { - return tds26qSummary(this.fy, this.asAt); - } - - /** - * The control that matters. Tax deducted must equal tax deposited plus tax - * still within its due date. Anything else means something has gone missing - * and must be found before the return is filed. - */ get reconciled(): boolean { - return this.summary.difference === 0; + return !!this.summary && Math.abs(this.summary.difference) < 1; } - get totalDefaultInterest(): number { - return this.summary.defaultInterest; + /** Opens the challan form, pre-filled for a month that still has tax outstanding. */ + openForm(row?: TdsRemittanceRow): void { + this.form = this.blankForm(); + if (row) { + this.form.month = row.month; + this.form.amount = row.outstanding; + } + this.formError = ''; + this.showForm = true; + } + + saveChallan(): void { + this.formError = ''; + const f = this.form; + if (!f.month || !f.amount || !f.challanNo || !f.bsrCode || !f.depositedOn || !f.paidFromGlAccountId) { + this.formError = 'Fill in every field marked *.'; + return; + } + this.saving = true; + this.tdsService.createChallan({ ...f, fy: this.fy, amount: Number(f.amount) }).subscribe({ + next: (challans) => { + this.saving = false; + this.showForm = false; + this.challans = challans; + this.recompute(); + }, + error: (err: any) => { + this.saving = false; + this.formError = + err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.'; + } + }); + } + + reverseChallan(c: TdsChallan): void { + if (!confirm(`Reverse challan ${c.challanNo} for ₹${c.amount}? Its accounting entry will be undone.`)) { + return; + } + this.tdsService.reverseChallan(c.id).subscribe({ + next: (challans) => { + this.challans = challans; + this.recompute(); + }, + error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not reverse the challan.') + }); + } + + private blankForm(): NewTdsChallan { + return { + fy: this.fy, + month: '', + amount: 0, + challanNo: '', + bsrCode: '', + depositedOn: '', + paidFromGlAccountId: 0, + remarks: '' + }; } trackRow(_: number, row: TdsRemittanceRow): string { - return `${row.fy}-${row.quarter}`; + return row.month; } async exportWorkbook(): Promise { + const s = this.summary; + if (!s) { + return; + } this.exporting = true; try { - const s = this.summary; const wb = new ExcelJS.Workbook(); const challan = wb.addWorksheet('Challans'); challan.addRow([`TDS remitted to Government — FY ${this.fy}`]); - challan.addRow([`Scope: ${this.scopeLabel} · as at ${this.asAt}`]); + challan.addRow([`Bank as a whole · as at ${this.asAt}`]); challan.addRow([]); challan.addRow([ 'Quarter', 'Deduction month', - 'Amount deducted', + 'Deducted', 'Due date', - 'Deposited on', - 'Challan no.', - 'BSR code', + 'Deposited', + 'Challans', + 'Outstanding', 'Status', 'Delay (months)', 'Late-deposit interest', @@ -107,12 +219,12 @@ export class TdsRemittanceReportComponent { challan.addRow([ `Q${r.quarter}`, r.month, - r.amount, + r.deducted, r.dueDate, - r.depositedOn ?? 'PENDING', - r.challanNo ?? '', - r.bsrCode ?? '', - r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.depositedOn ? 'On time' : 'Within due date', + r.deposited, + r.challans.map((c) => `${c.challanNo} / BSR ${c.bsrCode} / ${c.depositedOn}`).join('; '), + r.outstanding, + r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.outstanding ? 'Within due date' : 'Deposited', r.delayMonths || '', r.defaultInterest || '', r.returnDueDate @@ -151,7 +263,11 @@ export class TdsRemittanceReportComponent { s.tdsAtHigherRate ], [ - 'Total TDS deducted', + 'TDS due by the rules', + s.calculatedTds + ], + [ + 'Total TDS actually deducted', s.totalDeducted ], [ @@ -162,6 +278,10 @@ export class TdsRemittanceReportComponent { 'Still within due date', s.pendingDeposit ], + [ + 'Overdue', + s.overdueAmount + ], [ 'DIFFERENCE (must be zero)', s.difference @@ -169,14 +289,6 @@ export class TdsRemittanceReportComponent { [ 'Interest payable for late deposit', s.defaultInterest - ], - [ - 'Challans deposited late', - s.lateChallans - ], - [ - 'Challans overdue', - s.overdueChallans ] ]; for (const line of lines) { diff --git a/src/app/tds-reports/tds-reports-data.ts b/src/app/tds-reports/tds-reports-data.ts index f5c639c6e..4f753f880 100644 --- a/src/app/tds-reports/tds-reports-data.ts +++ b/src/app/tds-reports/tds-reports-data.ts @@ -7,33 +7,33 @@ */ /** - * Deductees and their deposit interest, for the two TDS reports. + * Deductees, their deposit interest and the challans, for the two TDS reports. * - * The bank's deduction obligation runs on time deposits, so the seed - * gives each customer a mix of fixed and recurring deposits — and some savings - * accounts, which are outside the section and must stay out of the aggregate. - * - * Everything is generated from a fixed seed so the figures do not move between - * loads, and the challan schedule is deliberately imperfect: one quarter is - * deposited late so the default-interest column has something to show. + * Everything here is what the platform returned. The challans used to be generated from the + * computed deductions - challan numbers, BSR codes and deposit dates made up, one quarter + * deliberately late - so the remittance report reconciled against figures nobody had entered. */ -import { GL_BRANCHES } from '../gl-reports/gl-reports-data'; import { + configuredFys, Declaration, - Form15Kind, InterestCredit, TdsAccountInput } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -/** The years the seed covers. 2025-26 is complete; 2026-27 is in progress. */ -export const TDS_FYS = [ - '2026-27', - '2025-26' -]; +/** The years the reports can show: every year configured on Organization › TDS Configuration. */ +export function tdsFys(): string[] { + return configuredFys(); +} -/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */ -export const TDS_AS_AT = '2026-08-01'; +/** A deposit as the platform reports it: the interest credited and the TDS actually withheld. */ +export interface TdsReportAccount extends TdsAccountInput { + /** TDS the platform actually deducted from this account in the year. */ + deductions: InterestCredit[]; + /** The account's own holder, when the tax is deducted against somebody else (minor, joint). */ + holderClientId?: number; + holderName?: string; +} export interface TdsDeductee { clientId: number; @@ -44,115 +44,35 @@ export interface TdsDeductee { branchCode: string; /** Declarations on record, keyed by financial year. */ declarations: Record; - accounts: TdsAccountInput[]; + accounts: TdsReportAccount[]; } -/** One month's remittance of deducted tax to the Government. */ +/** One challan Head Office entered: tax remitted to the Government. */ export interface TdsChallan { + id: number; fy: string; - /** Quarter the deduction belongs to. */ - quarter: 1 | 2 | 3 | 4; /** Month the tax was deducted in, e.g. '2026-06'. */ month: string; amount: number; - /** Statutory date by which it had to be deposited. */ - dueDate: string; - /** When it actually reached the Government — null while still pending. */ - depositedOn: string | null; - challanNo: string | null; - bsrCode: string | null; + challanNo: string; + bsrCode: string; + depositedOn: string; + paidFrom: string; + remarks: string | null; + reversed: boolean; } -/** Deterministic linear congruential generator — same figures on every load. */ -function lcg(seed: number): () => number { - let s = seed >>> 0; - return () => { - s = (Math.imul(s, 1664525) + 1013904223) >>> 0; - return s / 4294967296; - }; -} - -function pick(rand: () => number, list: T[]): T { - return list[Math.floor(rand() * list.length)]; -} - -const FIRST_NAMES = [ - 'Ramesh', - 'Sunita', - 'Vitthal', - 'Kamala', - 'Ganpat', - 'Shobha', - 'Dnyaneshwar', - 'Mangala', - 'Bhaskar', - 'Anita', - 'Tukaram', - 'Sarika', - 'Nivrutti', - 'Rekha', - 'Popat', - 'Chhaya', - 'Ashok', - 'Vaishali', - 'Sitaram', - 'Jayshree', - 'Madhukar', - 'Pushpa', - 'Eknath', - 'Sulochana', - 'Hanumant', - 'Kalpana' -]; - -const SURNAMES = [ - 'Jadhav', - 'Pawar', - 'Shinde', - 'Gaikwad', - 'More', - 'Kadam', - 'Bhosale', - 'Patil', - 'Sawant', - 'Chavan', - 'Salunkhe', - 'Mane', - 'Deshmukh', - 'Thorat', - 'Nikam', - 'Wagh' -]; - -/** - * Quarter-end dates for a financial year. Interest is credited on these, which - * is what makes the deduction fall due. - */ -function quarterEnds(fy: string): string[] { - const y = Number(fy.slice(0, 4)); - return [ - `${y}-06-30`, - `${y}-09-30`, - `${y}-12-31`, - `${y + 1}-03-31` - ]; -} - -/** A well-formed individual PAN — fourth character 'P'. */ -function makePan(rand: () => number, surname: string): string { - const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26)); - const digits = `${1000 + Math.floor(rand() * 9000)}`; - return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`; +/** Tax the platform withheld in one month. */ +export interface TdsDeductedMonth { + month: string; + amount: number; + postings: number; } /** * The deductees the reports run on, loaded from the platform. * - * This used to be sixty-four customers generated from a fixed seed — names, PANs, deposits and all. - * The computation over them is the real section 194A calculation, so the figures looked like a - * filing and were not one: not a rupee of it came from the bank's own books. - * - * It is set once by whoever loads the screen and read by the pure computation below, which is why + * It is set once by whoever loads the screen and read by the pure computation, which is why * this is a module-level holder rather than a service the compute would have to inject. */ let liveDeductees: TdsDeductee[] = []; @@ -171,7 +91,6 @@ export function tdsDeductees(): TdsDeductee[] { return liveDeductees; } - /** * Statutory date by which tax deducted in a given month must be deposited. * @@ -189,63 +108,3 @@ export function depositDueDate(month: string): string { const nextM = m === 12 ? 1 : m + 1; return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`; } - -let challanCache: Record | null = null; - -/** - * The remittance schedule, derived from what the engine says was deducted. - * - * Deductions land at each quarter end, so there is one challan per quarter. The - * Q2 challan of the prior year is deliberately deposited a month and a bit late - * so the late-deposit interest column is exercised, and the current year's latest - * quarter is left pending, which is the ordinary state of a live book. - */ -export function tdsChallans(deductedByQuarter: Record>): Record { - if (challanCache) { - return challanCache; - } - const out: Record = {}; - - for (const fy of TDS_FYS) { - const y = Number(fy.slice(0, 4)); - const months: Record = { - 1: `${y}-06`, - 2: `${y}-09`, - 3: `${y}-12`, - 4: `${y + 1}-03` - }; - const rows: TdsChallan[] = []; - - for (const q of [ - 1, - 2, - 3, - 4 - ] as (1 | 2 | 3 | 4)[]) { - const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0); - if (amount <= 0) { - continue; - } - const month = months[q]; - const dueDate = depositDueDate(month); - // Anything falling after the business date has not been deposited yet. - const pending = dueDate > TDS_AS_AT; - const late = fy === '2025-26' && q === 2; - - rows.push({ - fy, - quarter: q, - month, - amount, - dueDate, - depositedOn: pending ? null : late ? `${y}-11-14` : dueDate, - challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`, - bsrCode: pending ? null : '0510308' - }); - } - out[fy] = rows; - } - - challanCache = out; - return out; -} diff --git a/src/app/tds-reports/tds-reports.compute.spec.ts b/src/app/tds-reports/tds-reports.compute.spec.ts index b128afe9c..fd8fd919d 100644 --- a/src/app/tds-reports/tds-reports.compute.spec.ts +++ b/src/app/tds-reports/tds-reports.compute.spec.ts @@ -6,12 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT, TDS_FYS, depositDueDate, tdsDeductees } from './tds-reports-data'; +import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture'; +import { TdsChallan, TdsDeductee, depositDueDate, setTdsDeductees } from './tds-reports-data'; import { - NEAR_THRESHOLD_BAND, TdsReportFilter, - deductedByQuarter, tds26qSummary, tdsInterestRows, tdsInterestRowsForFy, @@ -19,12 +17,135 @@ import { tdsRemittanceRows } from './tds-reports.compute'; -/** The complete year in the seed — the one a return would be filed for. */ -const CLOSED_FY = '2025-26'; +installTdsConfigFixture(); + +/** + * A small book with every case the reports must handle once, so each figure below can be worked + * out by hand. The reports used to run on sixty-four generated customers; the tests asserted + * shapes ("some row crossed") because nobody could know the numbers. + */ +const FY = '2025-26'; +const Q = [ + '2025-06-30', + '2025-09-30', + '2025-12-31', + '2026-03-31' +]; + +const fd = (id: number, amounts: number[], extra: object = {}) => ({ + id, + accountNo: `FD${id}`, + depositTypeCode: 'depositAccountType.fixedDeposit', + credits: amounts.map((amount, i) => ({ date: Q[i], amount, accountNo: `FD${id}` })), + deductions: [] as { date: string; amount: number }[], + ...extra +}); + +const sb = (id: number, amount: number) => ({ + id, + accountNo: `SB${id}`, + depositTypeCode: 'depositAccountType.savingsDeposit', + credits: [{ date: '2025-09-30', amount, accountNo: `SB${id}` }], + deductions: [] as { date: string; amount: number }[] +}); + +function person( + clientId: number, + name: string, + dob: string, + pan: string, + branch: string, + accounts: any[], + declarations = {} +): TdsDeductee { + return { + clientId, + customerId: `C${clientId}`, + name, + pan, + dob, + branchCode: branch, + declarations, + accounts: accounts.map((a) => ({ clientId, ...a })) + }; +} + +const BOOK: TdsDeductee[] = [ + // Over the limit in Q4, plus a savings account that must stay outside the figure. + person(1, 'Ramesh', '1980-05-01', 'ABCPR1234K', 'Head Office', [ + { + ...fd( + 11, + [ + 15_000, + 15_000, + 15_000, + 15_000 + ] + ), + deductions: [{ date: Q[3], amount: 6_000 }] + }, + sb(12, 1_200) + ]), + // Senior citizen, no PAN: higher threshold, higher rate - and the platform deducted too little. + person(2, 'Sunita', '1955-01-01', '', 'bhopal', [ + { + ...fd( + 21, + [ + 30_000, + 30_000, + 30_000, + 30_000 + ] + ), + deductions: [{ date: Q[3], amount: 20_000 }] + } + ]), + // ₹6,000 short of the limit: the customer to ask for a declaration. + person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [ + fd( + 31, + [ + 22_000, + 22_000 + ] + ) + ]), + // Declaration accepted mid-year: interest before it stays liable, interest after it is paid gross. + person( + 4, + 'Kamala', + '1979-07-07', + 'ABCPK9012M', + 'Head Office', + [ + fd( + 41, + [ + 20_000, + 20_000, + 20_000, + 20_000 + ] + ) + ], + { + [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } + } + ), + // A minor's deposit, already listed under the guardian by the platform. + person(5, 'Ganpat', '1975-02-02', 'ABCPG3456N', 'Head Office', [ + { ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' }]), + // Savings only, no PAN: never deducted, so never counted as a no-PAN problem. + person(6, 'Shobha', '1990-09-09', '', 'Head Office', [sb(61, 900)]) +]; + +setTdsDeductees(BOOK); function filter(overrides: Partial = {}): TdsReportFilter { return { - fy: CLOSED_FY, + fy: FY, branchCode: '', quarter: 0, band: '', @@ -35,115 +156,94 @@ function filter(overrides: Partial = {}): TdsReportFilter { }; } -describe('the seed has every case the reports need to show', () => { - it('produces deductees on both sides of the threshold', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - expect(rows.length).toBeGreaterThan(20); - expect(rows.some((r) => r.crossed)).toBe(true); - expect(rows.some((r) => !r.crossed)).toBe(true); - expect(rows.some((r) => r.nearThreshold)).toBe(true); - expect(rows.some((r) => r.band === 'GENERAL')).toBe(true); - expect(rows.some((r) => r.band === 'SENIOR')).toBe(true); - expect(rows.some((r) => !r.panOnRecord)).toBe(true); - expect(rows.some((r) => r.hasDeclaration)).toBe(true); +const row = (name: string) => tdsInterestRowsForFy(FY).find((r) => r.deductee.name === name)!; + +describe('which accounts are tracked', () => { + it('counts the FD and leaves the savings account out, saying why', () => { + const r = row('Ramesh'); + expect(r.tdsApplicableInterest).toBe(60_000); + expect(r.exemptInterest).toBe(1_200); + expect(r.trackedAccounts).toBe(1); + const savings = r.accounts.find((a) => a.type === 'SB')!; + expect(savings.tracked).toBe(false); + expect(savings.reason).toContain('Savings'); + expect(r.accounts[0].type).toBe('FD'); }); - it('gives senior citizens the higher threshold', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - for (const row of rows) { - expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000); - } + it('shows whose account a deposit is when the tax falls on the guardian', () => { + const line = row('Ganpat').accounts[0]; + expect(line.tracked).toBe(true); + expect(line.holderName).toBe('Baby Ganpat'); }); - it('deducts at the higher rate wherever PAN is missing', () => { - for (const row of tdsInterestRowsForFy(CLOSED_FY)) { - expect(row.rateApplied).toBe(row.panOnRecord ? 0.1 : 0.2); - } + it('marks a savings-only customer as not tracked', () => { + expect(row('Shobha').trackedAccounts).toBe(0); }); }); -describe('the reports follow the Act that governed each year', () => { - it('shows 15G / 15H for FY 2025-26 and Form 121 for FY 2026-27', () => { - const old = tdsInterestRowsForFy('2025-26'); - expect(old.some((r) => r.form === '15G')).toBe(true); - expect(old.some((r) => r.form === '15H')).toBe(true); - expect(old.some((r) => r.form === '121')).toBe(false); - - // The two forms merged on 01 April 2026. - const current = tdsInterestRowsForFy('2026-27'); - expect(current.every((r) => r.form === '121')).toBe(true); +describe('the threshold and the rate', () => { + it('deducts on the whole year once the general limit is crossed', () => { + const r = row('Ramesh'); + expect(r.threshold).toBe(50_000); + expect(r.crossedInQuarter).toBe(4); + expect(r.schedule.totalTds).toBe(6_000); }); - it('keeps the age-based threshold band in both years', () => { - for (const fy of TDS_FYS) { - const rows = tdsInterestRowsForFy(fy); - expect(rows.some((r) => r.band === 'SENIOR')).toBe(true); - expect(rows.some((r) => r.band === 'GENERAL')).toBe(true); - for (const row of rows) { - expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000); - } - } + it('gives a senior the higher limit, and a missing PAN the higher rate', () => { + const r = row('Sunita'); + expect(r.band).toBe('SENIOR'); + expect(r.threshold).toBe(100_000); + expect(r.rateApplied).toBe(0.2); + expect(r.schedule.totalTds).toBe(24_000); + }); + + it('flags a customer just under the limit', () => { + const r = row('Vitthal'); + expect(r.crossed).toBe(false); + expect(r.nearThreshold).toBe(true); + expect(r.headroom).toBe(6_000); + expect(r.schedule.totalTds).toBe(0); }); }); -describe('savings interest stays out of the 194A aggregate', () => { - it('reports it separately and never inside the applicable interest', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - const withSavings = rows.filter((r) => r.exemptInterest > 0); - expect(withSavings.length).toBeGreaterThan(0); +describe('a mid-year declaration', () => { + it('taxes only the interest credited before it was accepted', () => { + const r = row('Kamala'); + expect(r.hasDeclaration).toBe(true); + // ₹40,000 credited before 15 Oct is liable once the year's ₹80,000 crosses the limit. + expect(r.schedule.totalTds).toBe(4_000); + expect(r.tdsSaved).toBe(4_000); + expect(r.paidWithoutDeduction).toBe(40_000); + }); +}); - for (const row of withSavings) { - // The applicable figure must equal only the time-deposit interest where - // this customer is the deductee. - const expected = row.deductee.accounts - .filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === row.deductee.clientId) - .flatMap((a) => a.credits) - .filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31') - .reduce((s, c) => s + c.amount, 0); - expect(row.tdsApplicableInterest).toBe(expected); - } +describe('due against actually deducted', () => { + it('agrees where the platform deducted the right amount', () => { + expect(row('Ramesh').actualTds).toBe(6_000); }); - it('leaves the savings totals out of the summary base', () => { + it('shows the gap where it did not', () => { + const r = row('Sunita'); + expect(r.actualTds).toBe(20_000); + expect(r.schedule.totalTds - r.actualTds).toBe(4_000); + }); + + it('totals both in the summary', () => { const s = tdsInterestSummary(filter()); - expect(s.exemptInterest).toBeGreaterThan(0); - expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0)); + expect(s.totalTds).toBe(6_000 + 24_000 + 4_000); + expect(s.actualTds).toBe(26_000); }); }); -describe('joint deposits are attributed to the first-named holder only', () => { - it('excludes second-holder interest from the aggregate and shows it apart', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - const secondHolders = rows.filter((r) => r.jointHeldInterest > 0); - expect(secondHolders.length).toBeGreaterThan(0); - - for (const row of secondHolders) { - const jointAccounts = row.deductee.accounts.filter( - (a) => isTdsApplicable(a) && tdsDeducteeOf(a) !== row.deductee.clientId - ); - expect(jointAccounts.length).toBeGreaterThan(0); - // None of that interest may appear in the figure the threshold is tested - // against, or one deposit would be taxed against two people. - const jointTotal = jointAccounts - .flatMap((a) => a.credits) - .filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31') - .reduce((s, c) => s + c.amount, 0); - expect(row.jointHeldInterest).toBe(jointTotal); - expect(row.tdsApplicableInterest).toBeLessThan(row.tdsApplicableInterest + jointTotal); - } - }); -}); - -describe('quarter figures add up to the year', () => { - it('per row', () => { - for (const row of tdsInterestRowsForFy(CLOSED_FY)) { - const quarterInterest = row.quarters[1] + row.quarters[2] + row.quarters[3] + row.quarters[4]; - expect(quarterInterest).toBe(row.tdsApplicableInterest); - expect(row.schedule.lines.reduce((s, l) => s + l.tds, 0)).toBe(row.schedule.totalTds); - } +describe('the summary', () => { + it('counts a missing PAN only where a deposit is being tracked', () => { + const s = tdsInterestSummary(filter()); + expect(s.noPan).toBe(1); + expect(s.trackedCustomers).toBe(5); + expect(s.exemptInterest).toBe(2_100); }); - it('and across the summary', () => { + it('adds the quarters up to the year', () => { const s = tdsInterestSummary(filter()); expect(s.byQuarter.reduce((acc, q) => acc + q.interest, 0)).toBe(s.applicableInterest); expect(s.byQuarter.reduce((acc, q) => acc + q.tds, 0)).toBe(s.totalTds); @@ -151,151 +251,128 @@ describe('quarter figures add up to the year', () => { }); describe('filters', () => { - it("narrows to a branch without changing anyone's figures", () => { - const all = tdsInterestRows(filter()); - const branch = all[0].branchCode; - const scoped = tdsInterestRows(filter({ branchCode: branch })); - expect(scoped.length).toBeGreaterThan(0); - expect(scoped.every((r) => r.branchCode === branch)).toBe(true); - // A branch view must not re-aggregate — the threshold is computed bank-wide. - const sample = scoped[0]; - const sameRow = all.find((r) => r.deductee.customerId === sample.deductee.customerId); - expect(sample.tdsApplicableInterest).toBe(sameRow?.tdsApplicableInterest); + it('narrows to a branch by office name, whatever its case, without re-aggregating', () => { + const scoped = tdsInterestRows(filter({ branchCode: 'BHOPAL' })); + expect(scoped.map((r) => r.deductee.name).sort()).toEqual([ + 'Sunita', + 'Vitthal' + ]); + expect(scoped.find((r) => r.deductee.name === 'Sunita')!.schedule.totalTds).toBe(24_000); }); - it('finds the customers worth chasing a declaration from', () => { - const near = tdsInterestRows(filter({ nearThresholdOnly: true })); - expect(near.length).toBeGreaterThan(0); - for (const row of near) { - expect(row.crossed).toBe(false); - expect(row.headroom).toBeGreaterThan(0); - expect(row.headroom).toBeLessThanOrEqual(NEAR_THRESHOLD_BAND); - } + it('finds the customers near the limit and those over it', () => { + expect(tdsInterestRows(filter({ nearThresholdOnly: true })).map((r) => r.deductee.name)).toEqual(['Vitthal']); + expect( + tdsInterestRows(filter({ crossedOnly: true })) + .map((r) => r.deductee.name) + .sort() + ).toEqual([ + 'Kamala', + 'Ramesh', + 'Sunita' + ]); }); - it('narrows to customers who crossed', () => { - const crossed = tdsInterestRows(filter({ crossedOnly: true })); - expect(crossed.length).toBeGreaterThan(0); - expect(crossed.every((r) => r.crossed && r.tdsApplicableInterest > r.threshold)).toBe(true); - }); - - it('searches on name, CIF and PAN', () => { - const target = tdsInterestRowsForFy(CLOSED_FY).find((r) => r.panOnRecord)!; - expect(tdsInterestRows(filter({ search: target.deductee.customerId })).length).toBe(1); - expect(tdsInterestRows(filter({ search: target.deductee.pan })).length).toBeGreaterThanOrEqual(1); + it('searches on name, customer ID and PAN', () => { + expect(tdsInterestRows(filter({ search: 'C3' })).length).toBe(1); + expect(tdsInterestRows(filter({ search: 'abcpk9012m' }))[0].deductee.name).toBe('Kamala'); }); }); -describe('declarations', () => { - it('save tax and record the interest paid gross', () => { - const declared = tdsInterestRowsForFy(CLOSED_FY).filter((r) => r.hasDeclaration); - expect(declared.length).toBeGreaterThan(0); - expect(declared.some((r) => r.tdsSaved > 0)).toBe(true); - // Interest paid without deduction is a mandatory 26Q disclosure, so it must - // be carried even though no tax came off it. - expect(declared.some((r) => r.paidWithoutDeduction > 0)).toBe(true); - for (const row of declared) { - expect(row.declarationUin).toBeTruthy(); - } +describe('remittance', () => { + const challan = (over: Partial): TdsChallan => ({ + id: 1, + fy: FY, + month: '2026-03', + amount: 0, + challanNo: '00001', + bsrCode: '0510308', + depositedOn: '2026-04-20', + paidFrom: '', + remarks: null, + reversed: false, + ...over }); - it('leaves tax deducted before a mid-year declaration in place', () => { - // Some declarations in the seed were accepted in October. Tax already taken - // in Q1/Q2 cannot be reversed, so those rows still carry a deduction. - const midYear = tdsInterestRowsForFy(CLOSED_FY).filter( - (r) => r.hasDeclaration && r.declarationAcceptedOn && r.declarationAcceptedOn.slice(5, 7) === '10' - ); - expect(midYear.length).toBeGreaterThan(0); - expect(midYear.some((r) => r.schedule.totalTds > 0)).toBe(true); - }); -}); - -describe('remittance schedule', () => { - it('deposits by the seventh of the following month, and 30 April for March', () => { + it('pays by the seventh of the following month, and 30 April for March', () => { expect(depositDueDate('2025-06')).toBe('2025-07-07'); expect(depositDueDate('2025-12')).toBe('2026-01-07'); expect(depositDueDate('2026-03')).toBe('2026-04-30'); }); - it('remits exactly what the engine says was deducted', () => { - const deducted = deductedByQuarter(CLOSED_FY); - const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT); - for (const row of rows) { - expect(row.amount).toBe(Math.round(deducted[row.quarter])); - } + it('marks a month paid in full and on time', () => { + const [r] = tdsRemittanceRows( + FY, + [{ month: '2026-03', amount: 26_000, postings: 2 }], + [challan({ amount: 26_000 })], + '2026-06-01' + ); + expect(r.quarter).toBe(4); + expect(r.outstanding).toBe(0); + expect(r.late).toBe(false); + expect(r.overdue).toBe(false); + expect(r.defaultInterest).toBe(0); }); - it('charges section 201(1A) interest on a late deposit and nothing on a timely one', () => { - const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT); - const late = rows.filter((r) => r.late); - expect(late.length).toBeGreaterThan(0); - for (const row of late) { - expect(row.delayMonths).toBeGreaterThan(0); - expect(row.defaultInterest).toBe(Math.round(row.amount * 0.015 * row.delayMonths)); - } - for (const row of rows.filter((r) => !r.late && !r.overdue)) { - expect(row.defaultInterest).toBe(0); - } + it('charges interest on a late challan', () => { + const [r] = tdsRemittanceRows( + FY, + [{ month: '2026-03', amount: 26_000, postings: 2 }], + [challan({ amount: 26_000, depositedOn: '2026-05-02' })], + '2026-06-01' + ); + expect(r.late).toBe(true); + expect(r.delayMonths).toBeGreaterThan(0); + expect(r.defaultInterest).toBeGreaterThan(0); }); -}); -describe('the 26Q reconciliation control', () => { - it.each(TDS_FYS)('balances for FY %s', (fy) => { - const s = tds26qSummary(fy, TDS_AS_AT); - // Deducted must equal deposited plus what is still within its due date. - // Anything else means tax has gone missing between the ledger and the - // Government, and the return must not be filed until it is found. + it('flags an unpaid month past its due date, and not one still within it', () => { + const rows = tdsRemittanceRows( + FY, + [ + { month: '2025-12', amount: 5_000, postings: 1 }, + { month: '2026-03', amount: 7_000, postings: 1 } + ], + [], + '2026-04-10' + ); + const dec = rows.find((r) => r.month === '2025-12')!; + const mar = rows.find((r) => r.month === '2026-03')!; + expect(dec.overdue).toBe(true); + expect(dec.defaultInterest).toBeGreaterThan(0); + expect(mar.overdue).toBe(false); + expect(mar.outstanding).toBe(7_000); + }); + + it('ignores a reversed challan', () => { + const [r] = tdsRemittanceRows( + FY, + [{ month: '2026-03', amount: 26_000, postings: 2 }], + [challan({ amount: 26_000, reversed: true })], + '2026-04-10' + ); + expect(r.deposited).toBe(0); + expect(r.outstanding).toBe(26_000); + }); + + it('reconciles deducted against paid plus still-within-due', () => { + const asAt = '2026-04-10'; + const rows = tdsRemittanceRows( + FY, + [ + { month: '2025-12', amount: 5_000, postings: 1 }, + { month: '2026-03', amount: 21_000, postings: 1 } + ], + [challan({ month: '2025-12', amount: 5_000, depositedOn: '2026-01-05' })], + asAt + ); + const s = tds26qSummary(FY, rows, asAt); + expect(s.totalDeducted).toBe(26_000); + expect(s.totalDeposited).toBe(5_000); + expect(s.pendingDeposit).toBe(21_000); expect(s.difference).toBe(0); - expect(s.totalDeducted).toBe(s.totalDeposited + s.pendingDeposit); - }); - - it('splits the tax between the standard and the no-PAN rate without loss', () => { - const s = tds26qSummary(CLOSED_FY, TDS_AS_AT); - expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(s.totalDeducted); - expect(s.tdsAtHigherRate).toBeGreaterThan(0); - }); - - it('splits interest into the deducted and the paid-gross halves', () => { - const s = tds26qSummary(CLOSED_FY, TDS_AS_AT); - expect(s.interestWithDeduction + s.interestWithoutDeduction).toBe(s.totalInterestPaid); - // The gross-paid half is the Form 121 disclosure and must not be zero here, - // or the report would never exercise that line. - expect(s.interestWithoutDeduction).toBeGreaterThan(0); - }); - - it('agrees with the interest report on the same year', () => { - const summary = tdsInterestSummary(filter()); - expect(tds26qSummary(CLOSED_FY, TDS_AS_AT).totalDeducted).toBe(summary.totalTds); - }); -}); - -describe('the part-run current year', () => { - it('has postings only up to the business date', () => { - for (const deductee of tdsDeductees()) { - for (const account of deductee.accounts) { - for (const credit of account.credits) { - expect(credit.date <= TDS_AS_AT).toBe(true); - } - } - } - }); - - it('shows the closed quarter as settled and future obligations as not yet due', () => { - const rows = tdsRemittanceRows('2026-27', TDS_AS_AT); - - // Q1 (Apr–Jun) has closed, so its tax was deducted and is due by 07 Jul. - expect(rows.some((r) => r.quarter === 1)).toBe(true); - - // Q3 cannot appear: no interest has posted there and nothing settles there. - expect(rows.some((r) => r.quarter === 3)).toBe(false); - - // Q4 *can* appear even in a part-run year — deposits on annual interest - // posting settle once, at year end — but that is a future obligation, so it - // must read as pending and must never be flagged overdue. - for (const row of rows.filter((r) => r.quarter === 4)) { - expect(row.depositedOn).toBeNull(); - expect(row.overdue).toBe(false); - expect(row.defaultInterest).toBe(0); - } + // The year's figures come from the same book as the interest report. + expect(s.calculatedTds).toBe(34_000); + expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(26_000); }); }); diff --git a/src/app/tds-reports/tds-reports.compute.ts b/src/app/tds-reports/tds-reports.compute.ts index afaa47d2a..547d0a933 100644 --- a/src/app/tds-reports/tds-reports.compute.ts +++ b/src/app/tds-reports/tds-reports.compute.ts @@ -17,7 +17,6 @@ import { DeclarationForm, - FY_TAX_CONFIG, Quarter, TdsMode, TdsSchedule, @@ -27,6 +26,7 @@ import { declarationFormFor, formKindFor, fyOf, + fyTaxConfigVersion, isSeniorInFy, isTdsApplicable, isWellFormedPan, @@ -35,7 +35,15 @@ import { tdsDeducteeOf, tdsModeFor } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees, tdsDeducteesVersion } from './tds-reports-data'; +import { + TdsChallan, + TdsDeductedMonth, + TdsDeductee, + TdsReportAccount, + depositDueDate, + tdsDeductees, + tdsDeducteesVersion +} from './tds-reports-data'; // ─────────────────────────────── Filtering ─────────────────────────────── @@ -55,6 +63,29 @@ export interface TdsReportFilter { nearThresholdOnly: boolean; } +/** + * One of the customer's accounts as the report sees it: whether its interest is being counted + * toward the deduction, and if not, why not. A customer can hold savings, fixed and recurring + * accounts together, and the branch has to see which of them the deduction is actually watching. + */ +export interface TdsAccountLine { + accountNo: string; + type: 'FD' | 'RD' | 'SB' | 'OTHER'; + /** True when the account's interest counts toward this customer's threshold. */ + tracked: boolean; + /** Plain-language reason, shown beside the account. */ + reason: string; + /** Interest credited in the year. */ + interest: number; + /** How many interest postings made up that figure. */ + postings: number; + lastPostedOn: string | null; + /** TDS the platform actually withheld from this account in the year. */ + tdsDeducted: number; + /** Set when the tax is deducted against someone other than the account's own holder. */ + holderName: string | null; +} + /** How close to the trigger counts as "worth chasing a declaration for". */ export const NEAR_THRESHOLD_BAND = 6_000; @@ -100,6 +131,15 @@ export interface TdsInterestRow { paidWithoutDeduction: number; panOnRecord: boolean; rateApplied: number; + /** Every account the customer holds, tracked or not. */ + accounts: TdsAccountLine[]; + /** How many of them count toward the deduction. */ + trackedAccounts: number; + /** + * TDS the platform actually withheld this year. `schedule.totalTds` is what this report works + * out is due; the two should agree, and a difference is the first thing to investigate. + */ + actualTds: number; } function emptyQuarters(): Record { @@ -118,7 +158,8 @@ let rowCache: TdsInterestRow[] = []; export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { // Keyed on the data set as well as the year: the rows are memoised, and a fresh load would // otherwise keep serving the previous customer's figures under the same financial year. - const key = fy + '|' + tdsDeducteesVersion; + // The tax configuration too: an administrator's save must not be hidden behind rows built on the old rates. + const key = fy + '|' + tdsDeducteesVersion + '|' + fyTaxConfigVersion; if (rowCacheKey === key) { return rowCache; } @@ -152,14 +193,39 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { let exemptInterest = 0; let jointHeldInterest = 0; + const accountLines: TdsAccountLine[] = []; for (const account of deductee.accounts) { - const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0); + const yearCredits = account.credits.filter((c) => fyOf(c.date) === fy); + const inYear = yearCredits.reduce((s, c) => s + c.amount, 0); + const type = accountTypeOf(account.depositTypeCode); + let tracked = false; + let reason: string; if (!isTdsApplicable(account)) { exemptInterest += inYear; + reason = type === 'SB' ? 'Savings account — no TDS on savings interest' : 'Not a time deposit — no TDS'; } else if (tdsDeducteeOf(account) !== deductee.clientId) { jointHeldInterest += inYear; + reason = 'Joint deposit — counted under the first-named holder'; + } else { + tracked = true; + reason = 'Its interest received in savings counts toward the TDS limit'; } + const report = account as TdsReportAccount; + accountLines.push({ + accountNo: account.accountNo || '', + type, + tracked, + reason, + interest: inYear, + postings: yearCredits.length, + lastPostedOn: yearCredits.reduce((last, c) => (!last || c.date > last ? c.date : last), null), + tdsDeducted: (report.deductions || []).filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0), + holderName: + report.holderClientId && report.holderClientId !== deductee.clientId ? (report.holderName ?? null) : null + }); } + // Tracked accounts first, so the deposits that drive the figure lead the list. + accountLines.sort((a, b) => Number(b.tracked) - Number(a.tracked)); const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere); const headroom = Math.max(0, schedule.threshold - schedule.totalInterest); @@ -188,7 +254,10 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0, paidWithoutDeduction: schedule.shieldedInterest, panOnRecord: isWellFormedPan(deductee.pan), - rateApplied: schedule.rateApplied + rateApplied: schedule.rateApplied, + accounts: accountLines, + trackedAccounts: accountLines.filter((a) => a.tracked).length, + actualTds: accountLines.reduce((s, a) => s + a.tdsDeducted, 0) }); } @@ -208,10 +277,29 @@ function overallModeFor(deductee: TdsDeductee): TdsMode { return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL'; } +function accountTypeOf(code: string | undefined): TdsAccountLine['type'] { + const c = (code || '').toLowerCase(); + if (c.includes('fixeddeposit')) { + return 'FD'; + } + if (c.includes('recurringdeposit')) { + return 'RD'; + } + if (c.includes('savings')) { + return 'SB'; + } + return 'OTHER'; +} + +/** Branches are compared by office name, case and spacing aside — the platform and the session both carry the name. */ +function sameBranch(a: string, b: string): boolean { + return (a || '').trim().toLowerCase() === (b || '').trim().toLowerCase(); +} + export function tdsInterestRows(filter: TdsReportFilter): TdsInterestRow[] { const needle = filter.search.trim().toLowerCase(); return tdsInterestRowsForFy(filter.fy).filter((row) => { - if (filter.branchCode && row.branchCode !== filter.branchCode) { + if (filter.branchCode && !sameBranch(row.branchCode, filter.branchCode)) { return false; } if (filter.band && row.band !== filter.band) { @@ -246,12 +334,16 @@ export interface TdsInterestSummary { /** Savings interest, outside the section. */ exemptInterest: number; totalTds: number; + /** TDS the platform actually withheld — set against `totalTds`, which is what is due. */ + actualTds: number; crossed: number; nearThreshold: number; declarations: number; tdsSaved: number; paidWithoutDeduction: number; noPan: number; + /** Customers holding at least one account whose interest counts toward the deduction. */ + trackedCustomers: number; byQuarter: { quarter: Quarter; interest: number; tds: number }[]; } @@ -273,26 +365,50 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary applicableInterest: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0), exemptInterest: rows.reduce((s, r) => s + r.exemptInterest, 0), totalTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0), + actualTds: rows.reduce((s, r) => s + r.actualTds, 0), crossed: rows.filter((r) => r.crossed).length, nearThreshold: rows.filter((r) => r.nearThreshold).length, declarations: rows.filter((r) => r.hasDeclaration).length, tdsSaved: rows.reduce((s, r) => s + r.tdsSaved, 0), paidWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0), - noPan: rows.filter((r) => !r.panOnRecord).length, + // Only customers with a deposit the deduction watches: a savings-only customer without a PAN + // is never deducted at the higher rate, so counting them overstated the problem. + noPan: rows.filter((r) => !r.panOnRecord && r.trackedAccounts > 0).length, + trackedCustomers: rows.filter((r) => r.trackedAccounts > 0).length, byQuarter }; } // ─────────────────────── Report 2: remittance ─────────────────────── -export interface TdsRemittanceRow extends TdsChallan { - /** Statutory due date has passed with nothing deposited. */ +/** + * One deduction month: what the platform withheld, and what Head Office remitted against it. + * + * Driven by the withhold-tax postings and the challans entered, never by this report's own + * computation — a return reconciles what was deducted, not what should have been. + */ +export interface TdsRemittanceRow { + fy: string; + quarter: Quarter; + /** Month the tax was deducted in, e.g. '2026-06'. */ + month: string; + deducted: number; + postings: number; + /** Live challans entered against this month. */ + challans: TdsChallan[]; + deposited: number; + lastDepositedOn: string | null; + dueDate: string; + /** Deducted but not yet remitted. */ + outstanding: number; + /** Remitted beyond what was deducted — a challan entered against the wrong month, usually. */ + excess: number; + /** Something is still outstanding and the due date has passed. */ overdue: boolean; - /** Deposited, but after the due date. */ + /** A challan reached the Government after the due date. */ late: boolean; - /** Months or part months of delay. */ delayMonths: number; - /** Interest charged for depositing late. */ + /** Interest the bank owes for depositing late. */ defaultInterest: number; /** Return the quarter's deduction is reported in. */ returnDueDate: string; @@ -305,41 +421,83 @@ const RETURN_DUE: Record = { 4: '31 May' }; -/** Tax deducted per quarter, from the engine, for one year. */ -export function deductedByQuarter(fy: string): Record { - const out: Record = { 1: 0, 2: 0, 3: 0, 4: 0 }; - for (const row of tdsInterestRowsForFy(fy)) { - for (const line of row.schedule.lines) { - out[line.quarter] += line.tds; - } +export function quarterOfMonth(month: string): Quarter { + const m = Number(month.slice(5, 7)); + if (m >= 4 && m <= 6) { + return 1; } - return out; + if (m >= 7 && m <= 9) { + return 2; + } + if (m >= 10) { + return 3; + } + return 4; } -export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] { - const deducted: Record> = {}; - for (const year of Object.keys(FY_TAX_CONFIG)) { - deducted[year] = deductedByQuarter(year); - } - const challans = tdsChallans(deducted)[fy] ?? []; +function round2(n: number): number { + return Math.round(n * 100) / 100; +} - return challans.map((c) => { - const overdue = !c.depositedOn && c.dueDate < asAt; - const late = !!c.depositedOn && c.depositedOn > c.dueDate; - // While a payment is overdue the clock runs to today; once paid it stops on - // the payment date. - const settledOn = c.depositedOn ?? asAt; - const delayMonths = late || overdue ? monthsOrPartBetween(c.dueDate, settledOn) : 0; - const defaultInterest = - late || overdue ? lateDeductionInterest(c.amount, c.dueDate, settledOn, 'DEPOSIT', fy).interest : 0; +export function tdsRemittanceRows( + fy: string, + deducted: TdsDeductedMonth[], + challans: TdsChallan[], + asAt: string +): TdsRemittanceRow[] { + const live = challans.filter((c) => !c.reversed && c.fy === fy); + const months = [ + ...new Set([ + ...deducted.map((d) => d.month), + ...live.map((c) => c.month) + ]) + ].sort(); + + return months.map((month) => { + const d = deducted.find((x) => x.month === month); + const amount = d?.amount ?? 0; + const forMonth = live.filter((c) => c.month === month); + const deposited = round2(forMonth.reduce((s, c) => s + c.amount, 0)); + const lastDepositedOn = forMonth.reduce( + (last, c) => (!last || c.depositedOn > last ? c.depositedOn : last), + null + ); + const dueDate = depositDueDate(month); + const outstanding = Math.max(0, round2(amount - deposited)); + const excess = Math.max(0, round2(deposited - amount)); + const overdue = outstanding > 0 && dueDate < asAt; + const lateChallans = forMonth.filter((c) => c.depositedOn > dueDate); + const late = lateChallans.length > 0; + + // Interest runs per challan from the due date to the day it was paid, and on whatever is still + // outstanding from the due date to today. + let defaultInterest = lateChallans.reduce( + (s, c) => s + lateDeductionInterest(c.amount, dueDate, c.depositedOn, 'DEPOSIT', fy).interest, + 0 + ); + if (overdue) { + defaultInterest += lateDeductionInterest(outstanding, dueDate, asAt, 'DEPOSIT', fy).interest; + } + const settledOn = overdue ? asAt : lastDepositedOn; + const delayMonths = (late || overdue) && settledOn ? monthsOrPartBetween(dueDate, settledOn) : 0; return { - ...c, + fy, + quarter: quarterOfMonth(month), + month, + deducted: amount, + postings: d?.postings ?? 0, + challans: forMonth, + deposited, + lastDepositedOn, + dueDate, + outstanding, + excess, overdue, late, delayMonths, defaultInterest, - returnDueDate: RETURN_DUE[c.quarter] + returnDueDate: RETURN_DUE[quarterOfMonth(month)] }; }); } @@ -347,9 +505,9 @@ export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] /** * The year-end position — what the quarterly TDS return has to reconcile to. * - * `difference` is the control figure: tax the engine says was deducted, less what - * actually reached the Government. Anything other than zero, once the pending - * challans are settled, means something is wrong and must be found before filing. + * `difference` is the control figure: tax actually deducted, less what reached the Government, + * less what is still within its due date. Anything other than zero means a month is overdue or + * a challan sits against the wrong month. */ export interface Tds26qSummary { fy: string; @@ -360,9 +518,13 @@ export interface Tds26qSummary { interestWithoutDeduction: number; tdsAtStandardRate: number; tdsAtHigherRate: number; + /** What this report works out was due. */ + calculatedTds: number; + /** What the platform actually withheld from accounts. */ totalDeducted: number; totalDeposited: number; pendingDeposit: number; + overdueAmount: number; /** Deducted less deposited less still-pending. Must be zero. */ difference: number; defaultInterest: number; @@ -370,29 +532,31 @@ export interface Tds26qSummary { overdueChallans: number; } -export function tds26qSummary(fy: string, asAt: string): Tds26qSummary { +export function tds26qSummary(fy: string, remittance: TdsRemittanceRow[], asAt: string): Tds26qSummary { const rows = tdsInterestRowsForFy(fy); - const challans = tdsRemittanceRows(fy, asAt); const standard = rows.filter((r) => r.panOnRecord); const higher = rows.filter((r) => !r.panOnRecord); - const totalDeducted = rows.reduce((s, r) => s + r.schedule.totalTds, 0); - const totalDeposited = challans.filter((c) => c.depositedOn).reduce((s, c) => s + c.amount, 0); - const pendingDeposit = challans.filter((c) => !c.depositedOn).reduce((s, c) => s + c.amount, 0); + const totalDeducted = round2(remittance.reduce((s, r) => s + r.deducted, 0)); + const totalDeposited = round2(remittance.reduce((s, r) => s + r.deposited, 0)); + const pendingDeposit = round2(remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0)); + const overdueAmount = round2(remittance.filter((r) => r.overdue).reduce((s, r) => s + r.outstanding, 0)); return { fy, - deductees: rows.length, + deductees: rows.filter((r) => r.trackedAccounts > 0).length, totalInterestPaid: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0), interestWithDeduction: rows.reduce((s, r) => s + r.schedule.liableInterest, 0), interestWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0), - tdsAtStandardRate: standard.reduce((s, r) => s + r.schedule.totalTds, 0), - tdsAtHigherRate: higher.reduce((s, r) => s + r.schedule.totalTds, 0), + tdsAtStandardRate: standard.reduce((s, r) => s + r.actualTds, 0), + tdsAtHigherRate: higher.reduce((s, r) => s + r.actualTds, 0), + calculatedTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0), totalDeducted, totalDeposited, pendingDeposit, - difference: Math.round(totalDeducted - totalDeposited - pendingDeposit), - defaultInterest: challans.reduce((s, c) => s + c.defaultInterest, 0), - lateChallans: challans.filter((c) => c.late).length, - overdueChallans: challans.filter((c) => c.overdue).length + overdueAmount, + difference: round2(totalDeducted - totalDeposited - pendingDeposit), + defaultInterest: remittance.reduce((s, r) => s + r.defaultInterest, 0), + lateChallans: remittance.filter((r) => r.late).length, + overdueChallans: remittance.filter((r) => r.overdue).length }; } diff --git a/src/app/tds-reports/tds-reports.service.ts b/src/app/tds-reports/tds-reports.service.ts index b8d1f4b07..27b7730d4 100644 --- a/src/app/tds-reports/tds-reports.service.ts +++ b/src/app/tds-reports/tds-reports.service.ts @@ -13,15 +13,52 @@ import { Observable, map } from 'rxjs'; /** Custom Helpers */ import { toIsoDate } from '../core/utils/api-date'; -import { TdsDeductee } from './tds-reports-data'; +import { Declaration, formKindFor } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; +import { TdsChallan, TdsDeductedMonth, TdsDeductee } from './tds-reports-data'; + +/** A declaration as recorded on the platform. */ +export interface TdsDeclarationRecord { + id: number; + clientId: number; + clientName?: string; + fy: string; + formType: '15G' | '15H' | '121'; + uin: string | null; + acceptedOn: string; + estimatedIncome: number | null; + revoked: boolean; + revokedOn: string | null; + revokeReason: string | null; +} + +export interface NewTdsDeclaration { + clientId: number; + fy: string; + formType: string; + acceptedOn: string; + uin?: string; + estimatedIncome?: number | null; +} + +export interface NewTdsChallan { + fy: string; + month: string; + amount: number; + challanNo: string; + bsrCode: string; + depositedOn: string; + paidFromGlAccountId: number; + remarks?: string; +} /** - * Deposit interest for the TDS reports. + * Deposit interest, declarations and challans for the TDS screens. * - * The platform returns the interest it actually credited — nothing else. A deposit is the - * customer's own money arriving and a fee is a charge, so neither is income and neither is sent. - * Working the figure out here from a balance and a rate would produce a number the passbook does - * not show. + * On an FD or RD the platform returns the interest that reached the customer's savings account and + * the TDS debited there for it — TDS is tracked and taken where the interest is received. A deposit + * is the customer's own money arriving and a fee is a charge, so neither is income and neither is + * sent. Working the figure out here from a balance and a rate would produce a number the passbook + * does not show. */ @Injectable({ providedIn: 'root' }) export class TdsReportsService { @@ -31,60 +68,131 @@ export class TdsReportsService { * @param financialYear the opening calendar year — 2026 means 1 Apr 2026 to 31 Mar 2027. * Omit to let the platform use the year the business date falls in. */ - getDepositInterest(financialYear?: number): Observable { - const params = financialYear ? new HttpParams().set('financialYear', String(financialYear)) : undefined; + getDepositInterest(financialYear?: number, clientId?: number): Observable { + let params = new HttpParams(); + if (financialYear) { + params = params.set('financialYear', String(financialYear)); + } + if (clientId) { + params = params.set('clientId', String(clientId)); + } return this.http .get('/tds/deposit-interest', { params }) .pipe(map((rows) => (rows || []).map((r) => this.toDeductee(r)))); } + getDeclarations(clientId: number): Observable { + return this.http.get('/tds/declarations', { + params: new HttpParams().set('clientId', String(clientId)) + }); + } + + createDeclaration(declaration: NewTdsDeclaration): Observable { + return this.http.post('/tds/declarations', declaration); + } + + revokeDeclaration(id: number, reason: string): Observable { + return this.http.post(`/tds/declarations/${id}/revoke`, { reason }); + } + + /** Tax actually withheld per month, and the business date the books are on. */ + getDeducted(fy: string): Observable<{ asAt: string; months: TdsDeductedMonth[] }> { + return this.http.get('/tds/deducted', { params: new HttpParams().set('fy', fy) }).pipe( + map((res) => ({ + asAt: res?.asAt ?? '', + months: (res?.months || []).map((m: any) => ({ + month: m.month, + amount: Number(m.amount) || 0, + postings: Number(m.postings) || 0 + })) + })) + ); + } + + getChallans(fy: string): Observable { + return this.http + .get('/tds/challans', { params: new HttpParams().set('fy', fy) }) + .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + } + + getChallanTemplate(): Observable<{ paidFromOptions: { id: number; glCode: string; name: string }[] }> { + return this.http.get('/tds/challans/template'); + } + + createChallan(challan: NewTdsChallan): Observable { + return this.http + .post('/tds/challans', challan) + .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + } + + reverseChallan(id: number): Observable { + return this.http + .post(`/tds/challans/${id}/reverse`, {}) + .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + } + + private toChallan(r: any): TdsChallan { + return { + id: r.id, + fy: r.fy, + month: r.month, + amount: Number(r.amount) || 0, + challanNo: r.challanNo, + bsrCode: r.bsrCode, + depositedOn: (r.depositedOn || '').slice(0, 10), + paidFrom: r.paidFrom || '', + remarks: r.remarks ?? null, + reversed: !!r.reversed + }; + } + /** - * Shapes one platform row into what the section 194A computation expects. + * Shapes one platform row into what the TDS computation expects. * * Dates arrive as [y, M, d] arrays and are flattened to ISO here, because the computation - * compares them as strings when deciding which financial year a credit falls in. + * compares them as strings when deciding which financial year a credit falls in. The platform + * has already grouped a minor's deposit under the guardian and a joint deposit under its primary + * holder, so every account here belongs to the deductee it is listed under. */ private toDeductee(row: any): TdsDeductee { + const dob = toIsoDate(row.dateOfBirth); + const credit = (a: any) => (c: any) => ({ + date: toIsoDate(c.date), + amount: Number(c.amount) || 0, + accountNo: c.accountNo || a.accountNo || '' + }); return { clientId: row.clientId, customerId: row.customerId || '', name: row.name || '', pan: row.pan || '', - dob: toIsoDate(row.dateOfBirth), + dob, branchCode: row.branchName || '', - // A 15G/15H on the customer's KYC record covers the year it was accepted for. The platform - // carries the kind but not the acceptance date, so the year's start stands in — a - // declaration is operative for the year it was given, which is what the computation needs. - declarations: this.declarationsOf(row), + declarations: this.declarationsOf(row.declarations || [], dob), accounts: (row.accounts || []).map((a: any) => ({ id: a.id, accountNo: a.accountNo || '', depositTypeCode: a.depositTypeCode || '', - accountStatus: a.accountStatus || undefined, + // The deductee, not the holder: the grouping above already resolved whose PAN it is. clientId: a.clientId ?? row.clientId, - credits: (a.credits || []).map((c: any) => ({ - date: toIsoDate(c.date), - amount: Number(c.amount) || 0, - accountNo: c.accountNo || a.accountNo || '' - })) + holderClientId: a.holderClientId ?? undefined, + holderName: a.holderName ?? undefined, + credits: (a.credits || []).map(credit(a)), + deductions: (a.deductions || []).map(credit(a)) })) }; } - private declarationsOf(row: any): Record { - const kind = (row.form15Type || '').trim().toUpperCase(); - if (kind !== '15G' && kind !== '15H') { - return {}; - } - const out: Record = {}; - // Applied to the years the reports can show. Without an acceptance date on the record the - // declaration is treated as operative from the start of each year rather than mid-year. - for (const year of [ - 2025, - 2026 - ]) { - const fy = `FY ${year}-${String(year + 1).slice(2)}`; - out[fy] = { kind, acceptedOn: `${year}-04-01`, fy }; + /** + * Declarations keyed by year, the form the computation looks them up by ('2026-27'). A Form 121 + * carries no G/H split, so the band follows the customer's age in that year. + */ + private declarationsOf(rows: any[], dob: string): Record { + const out: Record = {}; + for (const d of rows) { + const fy = d.fy; + const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(dob, fy); + out[fy] = { kind, acceptedOn: toIsoDate(d.acceptedOn), fy, uin: d.uin || undefined }; } return out; } diff --git a/src/app/tds-reports/tds-reports.shared.scss b/src/app/tds-reports/tds-reports.shared.scss index 8deb7825c..1fa00d346 100644 --- a/src/app/tds-reports/tds-reports.shared.scss +++ b/src/app/tds-reports/tds-reports.shared.scss @@ -647,3 +647,122 @@ color: var(--tdsr-ink); white-space: nowrap; } + +/* ── Help panel (interest report) ── */ +.tdsr__help { + margin-bottom: 16px; + padding: 14px 18px; + border: 1px solid color-mix(in srgb, var(--tdsr-blue) 25%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--tdsr-blue) 5%, transparent); + line-height: 1.6; +} + +.tdsr__help-title { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; + font-weight: 700; + color: var(--tdsr-blue); +} + +.tdsr__help-list { + margin: 0; + padding-left: 20px; + + li + li { + margin-top: 2px; + } +} + +/* ── Action chips under the summary ── */ +.tdsr__chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: -6px 0 16px; +} + +.tdsr__chip { + padding: 5px 12px; + border: 1px solid var(--tdsr-line); + border-radius: 999px; + background: var(--tdsr-bg); + font: inherit; + font-size: 12px; + font-weight: 600; + + &--near { + border-color: color-mix(in srgb, var(--tdsr-blue) 30%, transparent); + color: var(--tdsr-blue); + cursor: pointer; + } + + &--ok { + border-color: color-mix(in srgb, var(--tdsr-green) 35%, transparent); + color: var(--tdsr-green); + } + + &--warn { + border-color: color-mix(in srgb, var(--tdsr-amber) 35%, transparent); + color: var(--tdsr-amber); + } +} + +.tdsr__row--click { + cursor: pointer; + + &:hover > td { + background: var(--tdsr-bg); + } +} + +.tdsr__detail-h { + margin: 4px 0 8px; + font-weight: 700; + + .tdsr__table--inner + & { + margin-top: 16px; + } +} + +/* ── Challan form (remittance report) ── */ +.tdsr__form { + padding-bottom: 14px; +} + +.tdsr__filters--flat { + margin: 0; + border: 0; + box-shadow: none; +} + +.tdsr__form-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding: 0 16px; +} + +.tdsr__btn--primary { + border-color: var(--tdsr-blue); + background: var(--tdsr-blue); + color: #fff; +} + +.tdsr__btn--sm { + padding: 3px 10px; + font-size: 11.5px; +} + +.tdsr__link { + margin-left: 6px; + padding: 0; + border: 0; + background: none; + color: var(--tdsr-amber); + font: inherit; + text-decoration: underline; + cursor: pointer; +} diff --git a/src/app/tds-reports/tds-statutory.compute.ts b/src/app/tds-reports/tds-statutory.compute.ts index bbb50e748..87f74f0dc 100644 --- a/src/app/tds-reports/tds-statutory.compute.ts +++ b/src/app/tds-reports/tds-statutory.compute.ts @@ -32,7 +32,6 @@ import { quarterEnd, quarterOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT } from './tds-reports-data'; import { TdsInterestRow, tdsInterestRowsForFy } from './tds-reports.compute'; /** The bank's own deductor particulars, which head every filing. */ @@ -131,7 +130,7 @@ function monthLabel(key: string): string { * the portal rejects the line, and a silently dropped row is how a bank ends up * reporting fewer declarations than it holds. */ -export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): DeclarationStatement { +export function declarationStatement(fy: string, asAt: string): DeclarationStatement { const year = normaliseFy(fy); const cfg = FY_TAX_CONFIG[year]; if (!cfg) { diff --git a/src/app/web-app.component.ts b/src/app/web-app.component.ts index c27a3984f..5645ad422 100644 --- a/src/app/web-app.component.ts +++ b/src/app/web-app.component.ts @@ -8,7 +8,7 @@ /* eslint-disable @angular-eslint/prefer-inject */ /** Angular Imports */ -import { Component, OnInit, HostListener, HostBinding, OnDestroy } from '@angular/core'; +import { Component, OnInit, HostListener, HostBinding, OnDestroy, inject } from '@angular/core'; import { Router, NavigationEnd, ActivatedRoute } from '@angular/router'; import { Title } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -30,6 +30,7 @@ import { ThemeStorageService } from './shared/theme-picker/theme-storage.service import { AlertService } from './core/alert/alert.service'; import { AuthenticationService } from './core/authentication/authentication.service'; import { SettingsService } from './settings/settings.service'; +import { SystemService } from './system/system.service'; import { DocumentationLinksService } from 'app/shared/services/documentation-links.service'; import { IdleTimeoutService } from './home/timeout-dialog/idle-timeout.service'; import { SessionTimeoutDialogComponent } from './home/timeout-dialog/session-timeout-dialog.component'; @@ -102,6 +103,7 @@ export class WebAppComponent implements OnInit, OnDestroy { private authSubscription: Subscription; private destroy$ = new Subject(); + private systemService = inject(SystemService); /** * @param {Router} router Router for navigation. @@ -254,6 +256,27 @@ export class WebAppComponent implements OnInit, OnDestroy { } // Set default max date picker as Today this.settingsService.setBusinessDate(this.dateUtils.formatDate(new Date(), SettingsService.businessDateFormat)); + // ...then replace it with the bank's own "today" (business date when enabled, server date when + // not) once signed in, and again whenever the business date or its on/off switch changes. + // isAuthenticated$ fires at the start of a fresh login, before the auth header is set - a call + // then gets a 401 Basic challenge and the browser's own sign-in box. So it only covers a restored + // session (credentials already saved); a fresh login is caught by 'Authentication Success'. + this.authenticationService.isAuthenticated$.pipe(takeUntil(this.destroy$)).subscribe((loggedIn) => { + if (loggedIn && this.authenticationService.isAuthenticated()) { + this.refreshBusinessToday(); + } + }); + this.alertService.alertEvent + .pipe( + filter( + (alertEvent: Alert) => + alertEvent.type === 'Authentication Success' || + alertEvent.type === SettingsService.businessDateType + ' Set' || + alertEvent.type === SettingsService.businessDateType + ' Set Config' + ), + takeUntil(this.destroy$) + ) + .subscribe(() => this.refreshBusinessToday()); // Set the server list from the env var FINERACT_API_URLS, but avoid overwriting "Add new server" user choice if (!this.settingsService.servers) { this.settingsService.setServers(environment.baseApiUrls.split(',')); @@ -298,6 +321,19 @@ export class WebAppComponent implements OnInit, OnDestroy { } } + /** Stores the server's "today" where every date default reads it (see core/utils/business-today). */ + private refreshBusinessToday(): void { + this.systemService.getBusinessToday().subscribe({ + next: (res) => { + if (res?.today) { + this.settingsService.setBusinessDate(res.today); + } + }, + // Unreachable server or older backend: keep whatever date is already stored. + error: () => {} + }); + } + ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index 9160611c2..f5fd8d159 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -2236,6 +2236,9 @@ "Interest free period": "Interest free period", "Interest method": "Interest method", "Interest on savings": "Interest on savings", + "Fixed deposit control": "Fixed deposit control", + "Fixed deposit reference": "Fixed deposit reference", + "Interest on fixed deposits": "Interest on fixed deposits", "Interest rate": "Interest Rate", "Interest recalculation compounding on": "Interest recalculation compounding on", "Interest waived on": "Interest waived on", @@ -2793,6 +2796,7 @@ "Savings Product": "Savings Product", "Savings SubType": "Savings SubType", "Savings transfers in suspense": "Savings transfers in suspense", + "Transfers in suspense": "Transfers in suspense", "Schedule Date": "Schedule Date", "Share Details": "Share Details", "Scheduler Jobs": "Scheduler Jobs", @@ -2967,6 +2971,7 @@ "Transfer Interest to Savings Account": "Transfer Interest to Savings Account", "Transfer Type": "Transfer Type", "Transfer in suspense": "Transfer in suspense", + "Transfer to GL Account": "Transfer to GL Account", "Transfer to Savings": "Transfer to Savings", "Transfer to savings account": "Transfer to savings account", "Trigger Type": "Trigger Type", diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index d7885aac8..7e1be2f0b 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -19,8 +19,8 @@ export const environment = { version: env.mifos_x.version, hash: env.mifos_x.hash, // For connecting to server running elsewhere update the tenant identifier - fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'lincpay', - fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'lincpay', + fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'utkarshbank', + fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank,lincpay', // For connecting to others servers running elsewhere update the base API URL baseApiUrls: loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 700ff10f9..dabb366fb 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -22,8 +22,8 @@ export const environment = { version: env.mifos_x.version, hash: env.mifos_x.hash, // For connecting to server running elsewhere update the tenant identifier - fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'lincpay', - fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'lincpay', + fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'utkarshbank', + fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'utkarshbank,lincpay', // For connecting to others servers running elsewhere update the base API URL baseApiUrls: loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin, diff --git a/src/main.scss b/src/main.scss index 16f91750d..c74b84479 100644 --- a/src/main.scss +++ b/src/main.scss @@ -55,6 +55,19 @@ box-shadow: none; } +// ===== Payment / Renewal Dialog Panel ===== +.pmt-dialog-panel .mat-mdc-dialog-container .mdc-dialog__surface { + padding: 0 !important; + border-radius: 16px !important; + overflow: hidden !important; + box-shadow: 0 20px 40px rgb(15 23 42 / 25%) !important; +} + +.pmt-dialog-panel .mat-mdc-dialog-content { + padding: 0 !important; + max-height: none !important; +} + .ipd-dialog .mdc-dialog__surface { border-radius: 20px !important; background: transparent !important;