From 8bf74afe2469d8db80ef0a9c1a0fc6327dcf3059 Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 19 Oct 2026 18:16:22 +0530 Subject: [PATCH] Add CC accounts tracking, AML rule trial and branch approval fixes --- src/app/accounting/accounting.service.ts | 13 +- .../accounts-list.component.html | 7 + .../accounts-list/accounts-list.component.ts | 45 +- src/app/accounts/accounts-routing.module.ts | 6 + src/app/accounts/accounts.service.ts | 3 + src/app/aml/aml.service.ts | 26 + .../cash-transfer/cash-transfer.component.ts | 11 +- .../transaction-entry.component.html | 54 +- .../transaction-entry.component.scss | 36 + .../transaction-entry.component.ts | 99 +- .../disbursement-review.component.html | 45 +- .../disbursement-review.component.scss | 51 + .../disbursement-review.component.ts | 16 + .../client-general-step.component.html | 12 +- .../client-general-step.component.ts | 57 +- .../client-identity-check.component.html | 211 ++ .../client-identity-check.component.scss | 157 ++ .../client-identity-check.component.ts | 308 +++ .../client-kyc-step.component.ts | 46 +- .../clients-view/clients-view.component.html | 9 + .../general-tab/general-tab.component.html | 94 +- .../general-tab/general-tab.component.scss | 63 + .../general-tab/general-tab.component.ts | 69 + src/app/clients/clients.component.html | 22 + src/app/clients/clients.component.scss | 12 + src/app/clients/clients.component.ts | 28 +- src/app/clients/clients.service.ts | 140 +- .../create-client.component.html | 7 + .../create-client/create-client.component.ts | 47 + .../clients/models/client-identity.model.ts | 34 + .../clients/utils/aadhaar-hash.util.spec.ts | 52 + src/app/clients/utils/aadhaar-hash.util.ts | 185 ++ src/app/clients/utils/name-case.util.spec.ts | 19 +- src/app/clients/utils/name-case.util.ts | 14 + src/app/core/http/cache.interceptor.ts | 8 +- src/app/core/shell/shell.component.html | 11 +- src/app/core/shell/shell.component.ts | 19 + ...urrent-account-details-step.component.html | 32 +- ...urrent-account-details-step.component.scss | 38 + .../current-account-details-step.component.ts | 83 + .../directives/restrict-input.directive.ts | 7 +- src/app/fiu/fiu-data.ts | 2 + src/app/fiu/fiu.component.html | 372 +-- src/app/fiu/fiu.component.scss | 8 +- src/app/fiu/fiu.component.ts | 180 +- ...alm-bucket-breakdown-dialog.component.html | 852 +++++++ ...alm-bucket-breakdown-dialog.component.scss | 1341 ++++++++++ .../alm-bucket-breakdown-dialog.component.ts | 962 ++++++++ .../alm-bucket-breakdown.config.ts | 463 ++++ .../alm-bucket.util.ts | 209 ++ .../alm-maturity.service.ts | 125 + .../crr-slr-dialog.component.html | 310 +-- .../crr-slr-dialog.component.scss | 141 +- .../crr-slr-dialog.component.ts | 323 ++- .../reserve-transfer-dialog.component.html | 124 + .../reserve-transfer-dialog.component.scss | 223 ++ .../reserve-transfer-dialog.component.ts | 166 ++ .../home/dashboard/dashboard.component.html | 6 + .../home/dashboard/dashboard.component.scss | 14 + src/app/home/dashboard/dashboard.component.ts | 53 +- .../kyc-management.component.html | 1888 +++++++-------- .../kyc-management.component.scss | 5 +- .../kyc-management.component.ts | 2149 ++++++++++------- .../institution-profile.component.html | 6 +- .../institution-profile.component.ts | 6 +- .../organization-routing.module.ts | 7 + .../organization/organization.component.html | 20 + src/app/organization/organization.service.ts | 34 + .../reserve-fund-config.component.html | 359 +++ .../reserve-fund-config.component.scss | 143 ++ .../reserve-fund-config.component.ts | 308 +++ ...urrent-product-preview-step.component.html | 95 +- .../add-aml-rule-dialog.component.html | 25 +- .../add-aml-rule-dialog.component.scss | 29 + .../add-aml-rule-dialog.component.ts | 34 +- .../aml-cc-tracking.component.html | 595 +++++ .../aml-cc-tracking.component.scss | 770 ++++++ .../aml-cc-tracking.component.ts | 223 ++ .../aml-flagged-transactions.component.html | 287 ++- .../aml-flagged-transactions.component.scss | 28 +- .../aml-flagged-transactions.component.ts | 21 +- .../aml-recurring-alerts.component.scss | 6 + .../aml-rules/aml-rules.component.html | 69 +- .../aml-rules/aml-rules.component.scss | 39 + .../aml-rules/aml-rules.component.ts | 95 +- .../form9-return/form9-bank-format.ts | 480 ++++ .../form9-return/form9-return.component.html | 400 +-- .../form9-return/form9-return.component.scss | 126 + .../form9-return/form9-return.component.ts | 74 + .../rbi-circulars/rbi-circulars.service.ts | 39 + .../form1-statutory.component.scss | 303 +++ .../reserve-return/reserve-return.base.ts | 244 +- .../reserve-return.component.html | 260 +- .../create-savings-account.component.ts | 5 + ...w-journal-entry-transaction.component.html | 56 + ...w-journal-entry-transaction.component.scss | 92 + ...iew-journal-entry-transaction.component.ts | 40 +- .../gl-settings-dialog.component.ts | 145 +- .../deposit-requests.component.html | 26 +- .../deposit-requests.component.scss | 45 + 100 files changed, 14954 insertions(+), 2692 deletions(-) create mode 100644 src/app/clients/client-stepper/client-identity-check/client-identity-check.component.html create mode 100644 src/app/clients/client-stepper/client-identity-check/client-identity-check.component.scss create mode 100644 src/app/clients/client-stepper/client-identity-check/client-identity-check.component.ts create mode 100644 src/app/clients/models/client-identity.model.ts create mode 100644 src/app/clients/utils/aadhaar-hash.util.spec.ts create mode 100644 src/app/clients/utils/aadhaar-hash.util.ts create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.html create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.scss create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.ts create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown.config.ts create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket.util.ts create mode 100644 src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-maturity.service.ts create mode 100644 src/app/home/dashboard/crr-slr-dialog/reserve-transfer-dialog.component.html create mode 100644 src/app/home/dashboard/crr-slr-dialog/reserve-transfer-dialog.component.scss create mode 100644 src/app/home/dashboard/crr-slr-dialog/reserve-transfer-dialog.component.ts create mode 100644 src/app/organization/reserve-fund-config/reserve-fund-config.component.html create mode 100644 src/app/organization/reserve-fund-config/reserve-fund-config.component.scss create mode 100644 src/app/organization/reserve-fund-config/reserve-fund-config.component.ts create mode 100644 src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.html create mode 100644 src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.scss create mode 100644 src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.ts create mode 100644 src/app/rbi-circulars/form9-return/form9-bank-format.ts diff --git a/src/app/accounting/accounting.service.ts b/src/app/accounting/accounting.service.ts index 2143fbd22..5bc382ab0 100644 --- a/src/app/accounting/accounting.service.ts +++ b/src/app/accounting/accounting.service.ts @@ -10,7 +10,8 @@ /** Angular Imports */ import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'; +import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token'; /** rxjs Imports */ import { Observable } from 'rxjs'; @@ -198,6 +199,16 @@ export class AccountingService { return this.http.get('/journalentries/ledger', { params: httpParams }); } + /** + * The whole transfer a ledger transaction belongs to, when it is one branch's part of a transfer between branches. + * An extra, informational lookup: if it fails the screen simply shows no banner, so a failure raises no error popup. + */ + getInterBranchTransfer(transactionId: string): Observable { + return this.http.get(`/accounting/interbranchtransfers/${transactionId}`, { + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + /** * @param {string} transactionId Transaction ID to be reverted. * @param {string} comments Any comments. diff --git a/src/app/accounts/accounts-list/accounts-list.component.html b/src/app/accounts/accounts-list/accounts-list.component.html index 47a178301..c4246df84 100644 --- a/src/app/accounts/accounts-list/accounts-list.component.html +++ b/src/app/accounts/accounts-list/accounts-list.component.html @@ -139,6 +139,13 @@ + + Sanctioned Limit + + {{ getCurrencySymbol(row) }} {{ row.overdraftLimit | formatNumber }} + + + Balance diff --git a/src/app/accounts/accounts-list/accounts-list.component.ts b/src/app/accounts/accounts-list/accounts-list.component.ts index d1857bb71..93809df71 100644 --- a/src/app/accounts/accounts-list/accounts-list.component.ts +++ b/src/app/accounts/accounts-list/accounts-list.component.ts @@ -34,7 +34,7 @@ import { FormatNumberPipe } from '../../pipes/format-number.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { AccountsService } from '../accounts.service'; -export type AccountType = 'savings' | 'current' | 'fixed-deposits' | 'recurring-deposits'; +export type AccountType = 'savings' | 'current' | 'cc' | 'fixed-deposits' | 'recurring-deposits'; interface AccountConfig { label: string; @@ -119,6 +119,13 @@ export class AccountsListComponent implements OnInit, OnDestroy { typeLabel: 'CA', typeBadgeClass: 'dtype--ca' }, + cc: { + label: 'CC Accounts', + icon: 'hand-holding-usd', + accentClass: 'accent--current', + typeLabel: 'CC', + typeBadgeClass: 'dtype--ca' + }, 'fixed-deposits': { label: 'Fixed Deposit Accounts', icon: 'lock', @@ -142,6 +149,24 @@ export class AccountsListComponent implements OnInit, OnDestroy { ngOnInit() { this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => { this.accountType = data['accountType'] as AccountType; + this.displayedColumns = this.accountType === 'cc' ? [ + 'accountNo', + 'depositType', + 'clientName', + 'productName', + 'limit', + 'balance', + 'status', + 'actions' + ] : [ + 'accountNo', + 'depositType', + 'clientName', + 'productName', + 'balance', + 'status', + 'actions' + ]; this.pageIndex = 0; this.searchControl.setValue('', { emitEvent: false }); this.loadAccounts(); @@ -183,9 +208,12 @@ export class AccountsListComponent implements OnInit, OnDestroy { const toVal = this.toDateControl.value; const bothDatesSet = !!(fromVal && toVal); + // CC accounts come from the server already narrowed to those that allow an overdraft; nothing else is read. + const isCc = this.accountType === 'cc'; const params: import('../accounts.service').AccountListParams = { - offset: this.pageIndex * this.pageSize, - limit: this.pageSize, + offset: isCc ? 0 : this.pageIndex * this.pageSize, + limit: isCc ? 500 : this.pageSize, + ...(isCc && { overdraftOnly: true }), accountNo: this.searchType === 'accountNo' ? search || undefined : undefined, clientName: this.searchType === 'clientName' ? search || undefined : undefined, orderBy: 'id', @@ -199,7 +227,7 @@ export class AccountsListComponent implements OnInit, OnDestroy { const obs$ = this.accountType === 'savings' ? this.accountsService.getSavingsAccounts(params) - : this.accountType === 'current' + : this.accountType === 'current' || this.accountType === 'cc' ? this.accountsService.getCurrentAccounts(params) : this.accountType === 'fixed-deposits' ? this.accountsService.getFixedDepositAccounts(params) @@ -239,7 +267,7 @@ export class AccountsListComponent implements OnInit, OnDestroy { }); } - this.totalRecords = Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length); + this.totalRecords = isCc || Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length); this.dataSource.data = items; this.isLoading = false; }, @@ -307,6 +335,13 @@ export class AccountsListComponent implements OnInit, OnDestroy { id, 'general' ], + cc: [ + '/clients', + account.clientId, + 'current-accounts', + id, + 'general' + ], 'fixed-deposits': [ '/clients', account.clientId, diff --git a/src/app/accounts/accounts-routing.module.ts b/src/app/accounts/accounts-routing.module.ts index 228a5357f..b221fdd3e 100644 --- a/src/app/accounts/accounts-routing.module.ts +++ b/src/app/accounts/accounts-routing.module.ts @@ -36,6 +36,12 @@ const routes: Routes = [ component: AccountsListComponent, data: { accountType: 'current', title: 'Current Accounts', breadcrumb: 'Current Accounts' } }, + { + // Cash Credit: current accounts opened on a product that allows an overdraft. + path: 'cc', + component: AccountsListComponent, + data: { accountType: 'cc', title: 'CC Accounts', breadcrumb: 'CC Accounts' } + }, { path: 'fixed-deposits', component: AccountsListComponent, diff --git a/src/app/accounts/accounts.service.ts b/src/app/accounts/accounts.service.ts index 2f25c2917..d6cfaed6b 100644 --- a/src/app/accounts/accounts.service.ts +++ b/src/app/accounts/accounts.service.ts @@ -21,6 +21,8 @@ export interface AccountListParams { fromDate?: string; toDate?: string; asOnDate?: string; + /** Current accounts only: just the Cash Credit (overdraft) accounts, filtered by the server. */ + overdraftOnly?: boolean; } @Injectable({ providedIn: 'root' }) @@ -55,6 +57,7 @@ export class AccountsService { if (params.fromDate) httpParams = httpParams.set('fromDate', params.fromDate); if (params.toDate) httpParams = httpParams.set('toDate', params.toDate); if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate); + if (params.overdraftOnly) httpParams = httpParams.set('overdraftOnly', 'true'); return this.http.get('/currentaccounts', { params: httpParams }); } diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts index 047fed18c..451b9298b 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -142,6 +142,32 @@ export class AmlService { return this.http.get('/aml/ctr-reports/lines', { params }); } + /** + * Cash transactions of the threshold or more that no generated report contains yet, shaped like the lines above. + * They show on the FIU screen as soon as they are posted; generating the month is what files them. + */ + getCtrPendingLines(): Observable { + return this.http.get('/aml/ctr-reports/pending-lines'); + } + + /** + * How much came into and went out of each current / Cash Credit account over the last `days` days, with the + * customer's declared turnover and the sanctioned limit alongside. + */ + getCcTracking(days = 30): Observable { + return this.http.get('/aml/cc-tracking', { params: new HttpParams().set('days', String(days)) }); + } + + /** + * Every credit and debit on one CC account over the last `days` days, with the mode and counterparty each payment + * recorded, totalled by mode and by counterparty. + */ + getCcAccountTrail(accountId: number, days = 30): Observable { + return this.http.get(`/aml/cc-tracking/${accountId}/trail`, { + params: new HttpParams().set('days', String(days)) + }); + } + /** month format: yyyy-MM */ generateCtrReport(month: string): Observable { return this.http.post('/aml/ctr-reports/generate', { month }); diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.ts b/src/app/cashier/cash-transfer/cash-transfer.component.ts index ab9041425..a8370b4ff 100644 --- a/src/app/cashier/cash-transfer/cash-transfer.component.ts +++ b/src/app/cashier/cash-transfer/cash-transfer.component.ts @@ -19,6 +19,7 @@ import { of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; import { businessToday } from 'app/core/utils/business-today'; +import { AuthenticationService } from '../../core/authentication/authentication.service'; interface Denom { id: number; @@ -54,6 +55,7 @@ export class CashTransferComponent implements OnInit { @ViewChild('requestsDialog') requestsDialog!: TemplateRef; private requestsRef: MatDialogRef | null = null; + private authenticationService = inject(AuthenticationService); notes: Denom[] = []; coins: Denom[] = []; @@ -139,7 +141,14 @@ export class CashTransferComponent implements OnInit { .getTellers() .pipe(catchError(() => of([] as any[]))) .subscribe((list) => { - this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); + // The vault and its cash are physical, so a branch moves cash only from its own tellers. The + // backend lists every teller below the user's office (Head Office sees all branches), which is + // right for reports but wrong here. + const myOfficeId = this.authenticationService.getCredentials()?.officeId; + const own = (list || []).filter( + (t: any) => !myOfficeId || t.officeId == null || Number(t.officeId) === Number(myOfficeId) + ); + this.tellers = own.map((t: any) => ({ id: t.id, name: t.name })); if (this.tellers.length === 1) { this.onTellerSelect(this.tellers[0].id); } diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index c429ad732..d8d8fe7ce 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -132,12 +132,12 @@ @@ -722,7 +722,24 @@ } @else {
- Branch{{ officeName }} + Branch + @if (isOwnBranchAccount !== null) { + + {{ officeName }} + @if (!isOwnBranchAccount) { + Other branch + } + + } @else { + {{ officeName }} + } +
A/c No. - Branch {{ officeName ?? '—' }} + Branch + @if (isOwnBranchAccount !== null) { + + {{ officeName }} + @if (!isOwnBranchAccount) { + Other branch + } + + } @else { + {{ officeName }} + } + Product {{ @@ -1592,12 +1626,12 @@
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss index 06858c598..f56420d65 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.scss +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -3462,3 +3462,39 @@ select.te-cell { box-shadow: none; } } + +/* The branch an account is held at, coloured by where the signed-in user works: green for their own branch, red for any other. */ +.te-branch-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + border: 1px solid transparent; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + + &--mine { + background: #dcfce7; + border-color: #4ade80; + color: #166534; + } + + &--other { + background: #fca5a5; + border-color: #f87171; + color: #7f1d1d; + } +} + +.te-branch-chip__tag { + padding: 0 6px; + border-radius: 8px; + background: rgb(255 255 255 / 80%); + color: #7f1d1d; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index f0be400ff..63c04f4ee 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -10,7 +10,8 @@ import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } fr import { CommonModule } from '@angular/common'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { FormsModule } from '@angular/forms'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'; +import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token'; import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators'; import { forkJoin, of, Subject } from 'rxjs'; import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; @@ -1879,6 +1880,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; this.loadBranches(); this.addRow(); + this.loadBranchOptions(); this.loadDenominations(); this.loadOpeningPosition(); this.loadDayStatus(); @@ -2710,6 +2712,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } branchNameFor(branchId: number): string { + const seen = this.seenBranches.find((b) => b.id === branchId); + if (seen) return seen.name; return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`; } @@ -4328,6 +4332,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return false; } this.accountDetails = acc; + this.resolveAccountBranch(acc, acTyp, token, this.selectedRow); this.jointHolders = acc.jointHolders ?? []; // GL only: look up the parent (header) GL's name. Never runs for any other account type. this.glParent = null; @@ -4944,6 +4949,98 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ]; } + /** + * Whether the account on screen is held at the branch the signed-in user works at: true for their own branch, false for + * another branch's, null while either branch is not known (the branch is then shown plain, uncoloured). + */ + get isOwnBranchAccount(): boolean | null { + const mine = this.authService.getCredentials()?.officeId; + const accountOffice = this.accountDetails?.officeId ?? this.clientDetails?.officeId; + if (mine == null || accountOffice == null) return null; + return Number(mine) === Number(accountOffice); + } + + /** The bank's branches, for each row's BRANCH selector. Empty until /offices answers. */ + branchOptions: { id: number; name: string }[] = []; + + /** What the BRANCH selector offers: the real branches, or the old fixed list while they have not loaded. */ + get branchSelectOptions(): { id: number; name: string }[] { + const base = this.branchOptions.length ? this.branchOptions : TransactionEntryComponent.LEGACY_BRANCHES; + const merged = base.map((branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? branch); + for (const seen of this.seenBranches) { + if (!merged.some((branch) => branch.id === seen.id)) merged.push(seen); + } + return merged; + } + + /** Branches learned from the accounts picked on this screen; their names are real, whatever the office list says. */ + private seenBranches: { id: number; name: string }[] = []; + + private static readonly LEGACY_BRANCHES = [ + { id: 1, name: '001 · Main' }, + { id: 2, name: '002 · M.G. Road' }, + { id: 14, name: '014 · Station Rd' }, + { id: 27, name: '027 · Industrial' } + ]; + + /** + * The bank's branches for the BRANCH selector. Listing offices needs the READ offices permission, which a cashier or + * clerk may not hold, so a refusal must not raise an error popup: the selector then shows the branches this screen has + * learned from the accounts on it (and the old fixed list until then). + */ + private loadBranchOptions(): void { + this.http + .get('/offices', { context: new HttpContext().set(SILENT_HTTP_ERROR, true) }) + .pipe(catchError(() => of([]))) + .subscribe((offices) => { + this.branchOptions = (offices ?? []).map((o: any) => ({ id: o.id, name: o.name })); + }); + } + + /** + * The branch an account is held at. The savings, FD and RD detail responses carry no office, so the screen fell back + * to the client's own branch and an account opened at another branch read as the client's. The client's account list + * names each account's branch, so it is read from there. A current account's own response already carries it. + */ + private resolveAccountBranch(acc: any, acTyp: string, token: number, row: TxnRow | null | undefined): void { + if (acc?.officeId != null && acc?.officeName) { + this.adoptAccountBranch({ id: Number(acc.officeId), name: String(acc.officeName) }, row); + return; + } + const type = (acTyp ?? '').toUpperCase(); + if (!acc?.id || !acc?.clientId || ![ + 'SD', + 'FD', + 'RD', + 'CA' + ].includes(type)) return; + this.http + .get(`/clients/${acc.clientId}/accounts`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) }) + .pipe(catchError(() => of(null))) + .subscribe((res) => { + // stale: another account was picked while this was loading + if (token !== this.searchToken || this.accountDetails?.id !== acc.id) return; + const list: any[] = (type === 'CA' ? res?.currentAccounts : res?.savingsAccounts) ?? []; + const match = list.find((a: any) => a.id === acc.id); + if (match?.officeId != null && match?.officeName) { + this.adoptAccountBranch({ id: Number(match.officeId), name: String(match.officeName) }, row); + } + }); + } + + private adoptAccountBranch(branch: { id: number; name: string }, row: TxnRow | null | undefined): void { + this.accountDetails = { ...this.accountDetails, officeId: branch.id, officeName: branch.name }; + // /offices lists only the signed-in user's own branches, or nothing for a user who may not read offices; the account's + // branch must still be selectable, under its real name + if (!this.seenBranches.some((b) => b.id === branch.id)) { + this.seenBranches = [ + ...this.seenBranches, + branch + ]; + } + if (row) row.br = branch.id; + } + get openingDate(): string { const raw = this.accountDetails?.timeline?.submittedOnDate ?? diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.html b/src/app/clerk/disbursement-review/disbursement-review.component.html index 7ad308fd8..2844568b5 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.html +++ b/src/app/clerk/disbursement-review/disbursement-review.component.html @@ -102,6 +102,27 @@
} + + @if (isInterBranch) { +
+ alt_route +
+
Inter-branch transfer
+
+ @if (branchNames(drEntries) && branchNames(crEntries)) { + Money moves from {{ branchNames(drEntries) }} to + {{ branchNames(crEntries) }}. + } @else { + These accounts are held at different branches: {{ branchNames(creditEntries) }}. + } + Each account is posted in its own branch's books. Check the accounts before approving. +
+
+
+ } + @if (isBankTransferMode) {
@@ -154,6 +175,7 @@ Account No Account Name Type + Branch {{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }} @@ -166,11 +188,16 @@ {{ accountTypeLabel(entry.accountType) }} + + {{ + entry.officeName || '—' + }} + ₹{{ entry.amount | number: '1.2-2' }} } - {{ isCollectionMode ? 'Total Debit' : 'Total Credit' }} + {{ isCollectionMode ? 'Total Debit' : 'Total Credit' }} ₹{{ totalCreditAmount() | number: '1.2-2' }} @@ -192,6 +219,7 @@ Account No Account Name Type + Branch Amount (DR) @@ -204,11 +232,16 @@ {{ accountTypeLabel(entry.accountType) }} + + {{ + entry.officeName || '—' + }} + ₹{{ entry.amount | number: '1.2-2' }} } - Total Debit + Total Debit ₹{{ totalDebitAmount() | number: '1.2-2' }} @@ -228,6 +261,7 @@ Account No Account Name Type + Branch Amount (CR) @@ -240,11 +274,16 @@ {{ accountTypeLabel(entry.accountType) }} + + {{ + entry.officeName || '—' + }} + ₹{{ entry.amount | number: '1.2-2' }} } - Total Credit + Total Credit ₹{{ totalCreditAmount() | number: '1.2-2' }} diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.scss b/src/app/clerk/disbursement-review/disbursement-review.component.scss index a95e0293a..d1c1c63ef 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.scss +++ b/src/app/clerk/disbursement-review/disbursement-review.component.scss @@ -534,3 +534,54 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%); font-family: 'Courier New', monospace; letter-spacing: 0.02em; } + +/* A leg's branch. Amber on every leg when the request moves money between branches, neutral otherwise. */ +.dri-branch-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 20px; + background: #f1f5f9; + border: 1px solid #cbd5e1; + font-size: 11px; + font-weight: 700; + color: #475569; + + &--cross { + background: #fffbeb; + border-color: #fcd34d; + color: #92400e; + } +} + +/* Shown above the summary when the accounts are at different branches. */ +.dri-interbranch { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0 0 12px; + padding: 12px 16px; + border: 1px solid #fcd34d; + border-left: 4px solid #f59e0b; + border-radius: 8px; + background: #fffbeb; + color: #78350f; + + mat-icon { + flex-shrink: 0; + color: #d97706; + } + + &__title { + font-size: 13px; + font-weight: 800; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + &__text { + margin-top: 2px; + font-size: 13px; + line-height: 1.5; + } +} diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.ts b/src/app/clerk/disbursement-review/disbursement-review.component.ts index 82d96762f..f033916a5 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.ts +++ b/src/app/clerk/disbursement-review/disbursement-review.component.ts @@ -25,6 +25,9 @@ interface CreditEntry { accountType: string; amount: number; dbCr?: string; + /** The branch the account is held at; absent for a GL leg or when the server could not resolve it. */ + officeId?: number | null; + officeName?: string | null; } @Component({ @@ -167,6 +170,19 @@ export class DisbursementReviewComponent implements OnInit { return this.creditEntries.filter((e) => e.dbCr !== 'Dr'); } + /** + * True when the accounts in this request are held at more than one branch, so money moves between branches. The + * approver has to see that before approving: it is booked in each branch's own books. + */ + get isInterBranch(): boolean { + return new Set(this.creditEntries.filter((e) => e.officeId != null).map((e) => e.officeId)).size > 1; + } + + /** Distinct branch names among the given legs, in order, e.g. for "Head Office" or "Head Office, bhopal". */ + branchNames(entries: CreditEntry[]): string { + return [...new Set(entries.map((e) => e.officeName).filter((name): name is string => !!name))].join(', '); + } + totalCreditAmount(): number { const entries = this.isCollectionMode ? this.drEntries : this.crEntries; return entries.reduce((sum, e) => sum + (e.amount ?? 0), 0); diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index dbe9d8b20..ea661314d 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -930,6 +930,10 @@ createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched ) { Enter valid 10-digit number (starts with 6-9) + } @else if (createClientForm.get('mobileNo')?.hasError('contactTaken')) { + This mobile number already belongs to another customer. Please enter a different number. } @@ -940,7 +944,11 @@ color="primary" type="button" class="verify-btn" - [disabled]="readOnly || createClientForm.get('mobileNo')?.value?.length !== 10" + [disabled]=" + readOnly || + createClientForm.get('mobileNo')?.value?.length !== 10 || + createClientForm.get('mobileNo')?.hasError('contactTaken') + " (click)="verifyWithDIP()" > @@ -1008,6 +1016,8 @@ createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched ) { Email is required + } @else if (createClientForm.get('emailAddress')?.hasError('contactTaken')) { + This email already belongs to another customer. Please enter a different email. } @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) { Enter a valid email (e.g. name@example.com) } 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 db39bae63..a0785425e 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 @@ -27,15 +27,16 @@ import { UntypedFormControl, ReactiveFormsModule, ValidatorFn, + AsyncValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'; -import { Subject, Subscription, merge } from 'rxjs'; -import { filter, switchMap, takeUntil } from 'rxjs/operators'; +import { Subject, Subscription, merge, of, timer } from 'rxjs'; +import { catchError, filter, map, switchMap, takeUntil } from 'rxjs/operators'; import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { LegalFormId } from 'app/clients/models/legal-form.enum'; -import { toProperNameCase } from 'app/clients/utils/name-case.util'; +import { toPersonNameCase, toProperNameCase } from 'app/clients/utils/name-case.util'; import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service'; /** Custom Services */ @@ -936,6 +937,29 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges }; } + /** + * A mobile number or email that already belongs to another customer. This is a different person using a contact + * that is taken (a PAN or Aadhaar match is what means the same customer), so the answer is to enter another one. + * Runs only once the format validators pass, and a new keystroke cancels the previous call, which is what + * debounces it. The client being edited is excluded so its own number is not flagged. + */ + private contactTakenValidator(field: 'mobileNo' | 'emailAddress'): AsyncValidatorFn { + return (control: AbstractControl) => { + const value = (control.value ?? '').toString().trim(); + if (!value) { + return of(null); + } + return timer(400).pipe( + switchMap(() => + this.clientService.lookupClientIdentity({ [field]: value, excludeClientId: this.clientData?.id ?? null }) + ), + map((result) => (result?.contactConflicts?.[field] ? { contactTaken: true } : null)), + // A failed lookup must not block the form: the server still refuses a duplicate when the client is saved. + catchError(() => of(null)) + ); + }; + } + /** * Creates the client form. */ @@ -969,7 +993,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges Validators.required, Validators.pattern(/^[6-9]\d{9}$/), Validators.maxLength(10) - ] + ], + this.contactTakenValidator('mobileNo') ], alternateMobileNo: [ '', @@ -986,7 +1011,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges Validators.email, Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/), Validators.maxLength(100) - ] + ], + this.contactTakenValidator('emailAddress') ], dateOfBirth: [ '', @@ -1456,7 +1482,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges const control = this.createClientForm.get(controlName); if (control && control.value) { const originalValue = control.value; - const capitalized = toProperNameCase(originalValue); + // An entity's full name keeps its typed spelling (matched against the PAN name); a person's + // name parts are title-cased, the same as the Full Name line shows them. + const capitalized = + controlName === 'fullname' ? toProperNameCase(originalValue) : toPersonNameCase(originalValue); if (originalValue !== capitalized) { control.setValue(capitalized); } @@ -1925,12 +1954,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges /** Returns capitalized full name preview from first + middle + last */ get fullNamePreview(): string { - const capitalize = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : ''); const salutationId = this.createClientForm?.get('salutationId')?.value; const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || ''; - const first = capitalize(this.createClientForm?.get('firstname')?.value || ''); - const middle = capitalize(this.createClientForm?.get('middlename')?.value || ''); - const last = capitalize(this.createClientForm?.get('lastname')?.value || ''); + const first = toPersonNameCase(this.createClientForm?.get('firstname')?.value); + const middle = toPersonNameCase(this.createClientForm?.get('middlename')?.value); + const last = toPersonNameCase(this.createClientForm?.get('lastname')?.value); const full = [ first, middle, @@ -2360,6 +2388,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges if (generalDetails.fullname) { generalDetails.fullname = toProperNameCase(generalDetails.fullname); } + for (const part of [ + 'firstname', + 'middlename', + 'lastname' + ]) { + if (generalDetails[part]) { + generalDetails[part] = toPersonNameCase(generalDetails[part]); + } + } if (generalDetails.submittedOnDate instanceof Date) { generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat); diff --git a/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.html b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.html new file mode 100644 index 000000000..48152f1f6 --- /dev/null +++ b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.html @@ -0,0 +1,211 @@ + + +
+
+ fingerprint +
+

Existing customer check

+

+ @if (isEntity) { + Enter the company's GSTIN or PAN. If either is already on file in any branch, it is the same customer and a + new one is not created. + } @else { + Enter PAN or Aadhaar. If either is already on file in any branch, it is the same customer and a new one is not + created. Nothing found? What you enter here is filled into the KYC step for you. + } +

+
+
+ + @if (form) { +
+ @if (isEntity) { + + GSTIN + + @if (form.get('gstin')?.hasError('pattern')) { + Invalid GSTIN. It has 15 characters, like 22AAAAA0000A1Z5 + } + + } + + + {{ isEntity ? 'Company PAN' : 'PAN' }} + + @if (form.get('pan')?.hasError('pattern')) { + Invalid PAN. Use 5 letters, 4 digits, 1 letter, like ABCDE1234F + } + + + @if (!isEntity) { + + Aadhaar + + @if (form.get('aadhaar')?.hasError('pattern')) { + Invalid Aadhaar. It must be exactly 12 digits + } @else { + Only a fingerprint of this number is sent, never the number + } + + } +
+ } + + @switch (status) { + @case ('checking') { +
+ + Checking all branches… +
+ } + @case ('not-found') { +
+ check_circle + + No existing customer found. You can continue; these details will be filled into the KYC step so you do not + type them again. + +
+ } + @case ('error') { +
+ warning + + Could not check right now. You can continue; the PAN, Aadhaar and GSTIN are checked again when the KYC is + saved. + + +
+ } + @case ('conflict') { +
+ report + + These details belong to different customers. Check what was typed. If they are correct, ask a + manager to review before continuing. + +
+ @for (m of matches; track m.clientId) { +
+ {{ m.displayName }} + CIF {{ m.cifNumber }} · {{ m.officeName }} + + @for (by of m.matchedBy; track by) { + {{ matchedByLabel(by) }} matches + } + +
+ } + } + @case ('matched') { + @if (match; as m) { +
+ person_search + This is an existing customer. A new client is not created for the same person. +
+
+
+ {{ m.displayName }} + + @for (by of m.matchedBy; track by) { + {{ matchedByLabel(by) }} matches + } + +
+
+
+
CIF
+
{{ m.cifNumber }}
+
+
+
Home branch
+
{{ m.officeName }}
+
+
+
Type
+
{{ legalFormLabel(m) }}
+
+ @if (m.panMasked) { +
+
PAN
+
{{ m.panMasked }}
+
+ } + @if (m.aadhaarMasked) { +
+
Aadhaar
+
{{ m.aadhaarMasked }}
+
+ } + @if (m.gstinMasked) { +
+
GSTIN
+
{{ m.gstinMasked }}
+
+ } +
+ + @if (m.visibleToCaller) { + + } @else { +

+ This customer is registered at {{ m.officeName }}. They stay a single customer (one CIF). Open their page, then choose which account to open for them at + this branch. They are listed under this branch once an account is opened here. +

+ + } +
+ } + } + } +
diff --git a/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.scss b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.scss new file mode 100644 index 000000000..54cd2a206 --- /dev/null +++ b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.scss @@ -0,0 +1,157 @@ +/** + * 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 { + display: block; +} + +.identity-check { + margin-bottom: 16px; + padding: 16px 20px; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 12px; + + &--blocking { + border-color: #fecaca; + background: #fffafa; + } + + &__header { + display: flex; + gap: 12px; + align-items: flex-start; + margin-bottom: 12px; + } + + &__icon { + flex: none; + color: var(--md-sys-color-primary, #1074b9); + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 600; + } + + &__hint { + margin: 2px 0 0; + font-size: 13px; + color: #6b7280; + } + + // PAN and GSTIN read in capitals even before the value is rewritten. + &__upper { + text-transform: uppercase; + } + + &__fields { + display: flex; + flex-wrap: wrap; + gap: 0 16px; + } + + &__field { + flex: 1 1 220px; + min-width: 200px; + } + + &__status { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 12px; + font-size: 13px; + border-radius: 8px; + + &--info { + background: #eff6ff; + color: #1e40af; + } + + &--ok { + background: #ecfdf5; + color: #047857; + } + + &--warn { + background: #fffbeb; + color: #92400e; + } + + &--error { + background: #fef2f2; + color: #991b1b; + } + } + + &__card { + margin-top: 10px; + padding: 12px 14px; + background: #fff; + border: 1px solid #fecaca; + border-radius: 10px; + } + + &__card-head { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + justify-content: space-between; + } + + &__meta { + margin-left: 8px; + font-size: 13px; + color: #6b7280; + } + + &__chips { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + } + + &__chip { + padding: 2px 8px; + font-size: 12px; + color: #991b1b; + background: #fee2e2; + border-radius: 999px; + } + + &__facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px 16px; + margin: 10px 0 0; + + dt { + font-size: 12px; + color: #6b7280; + } + + dd { + margin: 0; + font-size: 14px; + font-weight: 500; + } + } + + &__action { + margin-top: 12px; + } + + &__note { + margin: 12px 0 0; + font-size: 13px; + color: #374151; + } +} diff --git a/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.ts b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.ts new file mode 100644 index 000000000..6dabb99a6 --- /dev/null +++ b/src/app/clients/client-stepper/client-identity-check/client-identity-check.component.ts @@ -0,0 +1,308 @@ +/** + * 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, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + inject +} from '@angular/core'; +import { + AbstractControl, + FormGroupDirective, + NgForm, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { Router } from '@angular/router'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { Observable, Subject, merge, of } from 'rxjs'; +import { catchError, debounceTime, map, switchMap, takeUntil, tap } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { RestrictInputDirective } from '../../../directives/restrict-input.directive'; +import { ClientsService } from '../../clients.service'; +import { ClientIdentityLookupResult, ClientIdentityMatch } from '../../models/client-identity.model'; +import { aadhaarHash } from '../../utils/aadhaar-hash.util'; + +export type ClientIdentityStatus = 'idle' | 'checking' | 'not-found' | 'matched' | 'conflict' | 'error'; + +/** + * What the parent needs from the identity check: whether creation must stop, and the identifiers the officer entered + * so the KYC step can be filled in without typing them again. + */ +export interface ClientIdentityState { + status: ClientIdentityStatus; + /** Individual: the person's PAN. Entity: the company's PAN. */ + pan: string | null; + /** The 12 digits, held in memory only to pre-fill the KYC step. Never sent: only its hash is. */ + aadhaar: string | null; + gstin: string | null; + /** True when the customer already exists (or the identifiers disagree), so a new client must not be created. */ + blocking: boolean; +} + +/** + * Shows a field's error as soon as something has been typed in it, not only after the field is left: a PAN that is + * wrong should say so while it is being typed, and the form should not look fine in the meantime. + */ +class ShowErrorWhenDirty implements ErrorStateMatcher { + isErrorState(control: AbstractControl | null, _form: FormGroupDirective | NgForm | null): boolean { + return !!control && control.invalid && (control.dirty || control.touched); + } +} + +const PAN_PATTERN = /^[A-Za-z]{5}\d{4}[A-Za-z]$/; +const AADHAAR_PATTERN = /^\d{12}$/; +const GSTIN_PATTERN = /^\d{2}[A-Za-z]{5}\d{4}[A-Za-z][1-9A-Za-z]Z[0-9A-Za-z]$/; + +/** + * "Is this person already a customer of the bank?" — asked at the top of the General step, before anything is saved. + * + * Any ONE of PAN / Aadhaar (individual) or PAN / GSTIN (entity) matching an existing customer, in any branch, means + * the same customer, so a second client must not be created. When nothing matches, what was entered is carried to the + * KYC step so it does not have to be typed again. + */ +@Component({ + selector: 'mifosx-client-identity-check', + templateUrl: './client-identity-check.component.html', + styleUrls: ['./client-identity-check.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIcon, + MatProgressSpinner, + RestrictInputDirective + ] +}) +export class ClientIdentityCheckComponent implements OnInit, OnChanges, OnDestroy { + private formBuilder = inject(UntypedFormBuilder); + private clientsService = inject(ClientsService); + private router = inject(Router); + private authenticationService = inject(AuthenticationService); + + /** 1 = individual, 2 = entity (non-individual). */ + @Input() legalFormType = 1; + @Output() identityChange = new EventEmitter(); + + form: UntypedFormGroup; + readonly errorMatcher = new ShowErrorWhenDirty(); + status: ClientIdentityStatus = 'idle'; + matches: ClientIdentityMatch[] = []; + /** True while the customer is being introduced to this branch. */ + linking = false; + + private destroy$ = new Subject(); + private recheck$ = new Subject(); + + get isEntity(): boolean { + return this.legalFormType === 2; + } + + /** The first (and normally only) existing customer found. */ + get match(): ClientIdentityMatch | null { + return this.matches[0] ?? null; + } + + /** + * True while a field holds something that is not a valid PAN / Aadhaar / GSTIN. Such a value is never looked up or + * carried to the KYC step, so the parent must not let the officer carry on past it as if the check had passed. + */ + get hasInvalidInput(): boolean { + return !!this.form?.invalid; + } + + markAllAsTouched(): void { + this.form?.markAllAsTouched(); + } + + get state(): ClientIdentityState { + const v = this.normalized(); + return { + status: this.status, + pan: v.pan, + aadhaar: v.aadhaar, + gstin: v.gstin, + blocking: this.status === 'matched' || this.status === 'conflict' + }; + } + + ngOnInit(): void { + this.form = this.formBuilder.group({ + pan: [ + '', + Validators.pattern(PAN_PATTERN) + ], + aadhaar: [ + '', + Validators.pattern(AADHAAR_PATTERN) + ], + gstin: [ + '', + Validators.pattern(GSTIN_PATTERN) + ] + }); + + // A pause in typing (or a manual re-check) runs one lookup; typing again cancels the one in flight. + merge(this.form.valueChanges, this.recheck$) + .pipe( + debounceTime(400), + tap(() => this.startChecking()), + switchMap(() => this.lookup()), + takeUntil(this.destroy$) + ) + .subscribe((result) => this.applyResult(result)); + } + + ngOnChanges(changes: SimpleChanges): void { + // Switching individual <-> entity swaps which identifiers apply; what was typed for the other kind is stale. + if (changes['legalFormType'] && !changes['legalFormType'].firstChange) { + this.reset(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** Clears the fields and the result, e.g. when a new client creation session starts. */ + reset(): void { + this.form?.reset({ pan: '', aadhaar: '', gstin: '' }, { emitEvent: false }); + this.status = 'idle'; + this.matches = []; + this.identityChange.emit(this.state); + } + + recheck(): void { + this.recheck$.next(); + } + + /** + * PAN and GSTIN are always written in capitals, so what is typed is capitalised as it is typed. The caret is put + * back where it was, otherwise editing the middle of a PAN would throw it to the end. The shared restrict-input + * directive only capitalises when the field is left, which let lower-case show while typing. + */ + capitalise(controlName: 'pan' | 'gstin', event: Event): void { + const input = event.target as HTMLInputElement; + const upper = input.value.toUpperCase(); + if (upper !== input.value) { + const { selectionStart, selectionEnd } = input; + input.value = upper; + input.setSelectionRange(selectionStart, selectionEnd); + } + this.form.get(controlName)?.setValue(upper, { emitEvent: false }); + } + + /** Identifiers that are complete and well formed; anything half-typed is ignored rather than looked up. */ + private normalized(): { pan: string | null; aadhaar: string | null; gstin: string | null } { + const raw = this.form?.getRawValue() ?? { pan: '', aadhaar: '', gstin: '' }; + const pan = String(raw.pan ?? '') + .replace(/\s+/g, '') + .toUpperCase(); + const aadhaar = String(raw.aadhaar ?? '').replace(/[\s-]/g, ''); + const gstin = String(raw.gstin ?? '') + .replace(/\s+/g, '') + .toUpperCase(); + return { + pan: PAN_PATTERN.test(pan) ? pan : null, + aadhaar: !this.isEntity && AADHAAR_PATTERN.test(aadhaar) ? aadhaar : null, + gstin: this.isEntity && GSTIN_PATTERN.test(gstin) ? gstin : null + }; + } + + private startChecking(): void { + const v = this.normalized(); + if (!v.pan && !v.aadhaar && !v.gstin) { + this.status = 'idle'; + this.matches = []; + this.identityChange.emit(this.state); + return; + } + this.status = 'checking'; + this.matches = []; + this.identityChange.emit(this.state); + } + + private lookup(): Observable { + const v = this.normalized(); + if (!v.pan && !v.aadhaar && !v.gstin) { + return of(null); + } + return this.clientsService + .lookupClientIdentity({ panNumber: v.pan, aadhaarHash: aadhaarHash(v.aadhaar), gstinNumber: v.gstin }) + .pipe( + map((result): ClientIdentityLookupResult | 'error' => result), + catchError(() => of('error' as const)) + ); + } + + private applyResult(result: ClientIdentityLookupResult | 'error' | null): void { + if (result === null) { + // Nothing complete to look up; startChecking() already reset to idle. + return; + } + if (result === 'error') { + // Not blocking: the server checks again when the KYC is saved, so a failed lookup must not stop onboarding. + this.status = 'error'; + this.matches = []; + } else if (result.conflict) { + this.status = 'conflict'; + this.matches = result.matches; + } else if (result.matched) { + this.status = 'matched'; + this.matches = result.matches; + } else { + this.status = 'not-found'; + this.matches = []; + } + this.identityChange.emit(this.state); + } + + /** + * Starts opening an account at THIS branch for a customer registered at another one. The customer stays at the home + * branch (one CIF). This branch is introduced to them, which only lets it open their page; they are listed under + * this branch once an account is actually opened here. An account opened for a customer of another branch is held + * at the branch that opens it. + */ + openAccountHere(match: ClientIdentityMatch): void { + const officeId = this.authenticationService.getCredentials()?.officeId; + if (this.linking || officeId == null) { + return; + } + this.linking = true; + this.clientsService.linkClientToMyBranch(match.clientId).subscribe({ + next: () => { + this.linking = false; + this.router.navigate([ + '/clients', + match.clientId, + 'general' + ]); + }, + // The failure is shown by the global error handler. + error: () => (this.linking = false) + }); + } + + /** Legal form of the existing customer, for the card. */ + legalFormLabel(match: ClientIdentityMatch): string { + return match.legalFormEnum === 2 ? 'Non-Individual' : 'Individual'; + } + + matchedByLabel(by: string): string { + return by === 'AADHAAR' ? 'Aadhaar' : by; + } +} 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 cfca4fb8c..2c024b758 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 @@ -43,6 +43,8 @@ import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component'; import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component'; import { dataUrlToFile } from 'app/shared/utils/data-url.util'; +import { aadhaarHash } from '../../utils/aadhaar-hash.util'; +import { ClientIdentityState } from '../client-identity-check/client-identity-check.component'; import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component'; import { PanDeclarationDialogComponent, @@ -108,6 +110,32 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { @Input() verificationDateOfBirth: Date | null = null; /** Read-only mode — disables form editing until user clicks edit icon */ @Input() readOnly = false; + + /** + * The identifiers the officer already typed into the existing-customer check on the General step. They are filled + * in here so they are not typed twice, but only into fields that are still empty: whatever was entered on this + * step wins. Ignored while the check says the customer already exists, since no client is created then. + */ + @Input() set identityPrefill(identity: ClientIdentityState | null) { + if (!identity || identity.blocking) { + return; + } + const isEmpty = (name: string) => !String(this.kycForm?.get(name)?.value ?? '').trim(); + const patch: Record = {}; + if (this.isNonIndividual) { + if (identity.gstin && isEmpty('gstinNumber')) patch['gstinNumber'] = identity.gstin; + if (identity.pan && isEmpty('enterprisePan')) patch['enterprisePan'] = identity.pan; + } else { + if (identity.pan && isEmpty('panNumber')) { + patch['panNumber'] = identity.pan; + patch['hasPan'] = true; + } + if (identity.aadhaar && isEmpty('aadhaarNumber')) patch['aadhaarNumber'] = identity.aadhaar; + } + if (Object.keys(patch).length) { + this.kycForm.patchValue(patch); + } + } @Input() calculatedRisk: { category: number; reason: string; @@ -1756,7 +1784,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { kycType: this.kycTypeValue, riskCategory: this.currentRiskCategory, panNumber: pan, - aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000' + aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000', + ...this.aadhaarHashField(aadhaarRaw) }; // A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is @@ -2358,7 +2387,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { locale: 'en', kycType: this.kycTypeValue, riskCategory: this.currentRiskCategory, - aadhaarNumberMasked: aadhaarMasked + aadhaarNumberMasked: aadhaarMasked, + ...this.aadhaarHashField(aadhaar) }; const panVal = this.kycForm.value.panNumber; if (panVal) { @@ -2679,6 +2709,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { } } + /** + * `{ aadhaarHash }` for a full 12-digit Aadhaar, `{}` otherwise. The number itself is never sent: KYC calls are + * recorded server-side, so only its SHA-256 goes. + */ + private aadhaarHashField(aadhaar: string | null | undefined): { aadhaarHash?: string } { + const hash = aadhaarHash(aadhaar); + return hash ? { aadhaarHash: hash } : {}; + } + get kycData(): any { const formVal = this.kycForm.getRawValue(); const aadhaarRaw = formVal.aadhaarNumber || ''; @@ -2707,6 +2746,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { // For non-individual: Company PAN is used as panNumber for verification panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null, aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked, + // The fingerprint of the full number, so the server can tell two Aadhaar numbers apart (the masked form keeps + // only the last four digits). Absent when the field holds a masked value reloaded from the server. + ...(this.isNonIndividual ? {} : this.aadhaarHashField(aadhaarRaw)), // Only ever sent when this screen actually holds a value. The seeding status is derived by // the provider during PAN verification and written straight to the record, so sending an // empty one back would blank a field the server filled in — which reads as an officer diff --git a/src/app/clients/clients-view/clients-view.component.html b/src/app/clients/clients-view/clients-view.component.html index f7f537f85..eb0cbd58a 100644 --- a/src/app/clients/clients-view/clients-view.component.html +++ b/src/app/clients/clients-view/clients-view.component.html @@ -356,6 +356,15 @@ + + diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.html b/src/app/clients/clients-view/general-tab/general-tab.component.html index 7902eb818..6a2cbab2e 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.html +++ b/src/app/clients/clients-view/general-tab/general-tab.component.html @@ -90,6 +90,21 @@ + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + {{ 'labels.inputs.Last Active' | translate }} {{ element.lastActiveTransactionDate | dateFormat }} @@ -203,6 +218,21 @@ + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + {{ 'labels.inputs.Closed Date' | translate }} {{ element.timeline.closedOnDate | dateFormat }} @@ -262,6 +292,21 @@ + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + {{ 'labels.inputs.Last Active' | translate }} {{ element.lastActiveTransactionDate | dateFormat }} @@ -452,6 +497,21 @@ {{ 'labels.inputs.Fixed Deposit Product' | translate }} {{ element.productName }} + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + {{ 'labels.inputs.Last Active' | translate }} {{ element.lastActiveTransactionDate | dateFormat }} @@ -551,6 +611,21 @@ + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + {{ 'labels.inputs.Closed Date' | translate }} {{ element.timeline.closedOnDate | dateFormat }} @@ -611,10 +686,27 @@ + + {{ 'labels.inputs.Branch' | translate }} + + @if (element.officeName) { + + {{ element.officeName }} + @if (isHomeBranch(element)) { + Home + } + + } @else { + — + } + + Status - {{ rdStatusLabel(element) }} + {{ + rdStatusLabel(element) + }} @if (element.timeline?.closedOnDate) {
on {{ element.timeline.closedOnDate | dateFormat }}
} diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.scss b/src/app/clients/clients-view/general-tab/general-tab.component.scss index 33fd52124..1c1f14729 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.scss +++ b/src/app/clients/clients-view/general-tab/general-tab.component.scss @@ -440,3 +440,66 @@ table[mat-table] { font-size: 11px; color: #64748b; } + +/* + * Branch an account is held at, as a pill coloured by where the signed-in user works: green for their own branch, red for + * any other. When the customer banks at several other branches the reds run from light to dark, one shade per branch. + */ +.branch-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + + .fa { + font-size: 12px; + } + + &--mine { + background: #dcfce7; + color: #166534; + border-color: #4ade80; + } + + &--other-0 { + background: #fee2e2; + color: #991b1b; + border-color: #fca5a5; + } + + &--other-1 { + background: #fca5a5; + color: #7f1d1d; + border-color: #f87171; + } + + &--other-2 { + background: #dc2626; + color: #fff; + border-color: #b91c1c; + } + + &--other-3 { + background: #991b1b; + color: #fff; + border-color: #7f1d1d; + } +} + +.branch-chip__home { + padding: 0 6px; + border-radius: 8px; + + /* fixed colours: the chip text turns white on the dark reds, which would vanish against this light tag */ + background: rgb(255 255 255 / 85%); + color: #1f2937; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.ts b/src/app/clients/clients-view/general-tab/general-tab.component.ts index 5382d6c2a..9a8db4816 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.ts +++ b/src/app/clients/clients-view/general-tab/general-tab.component.ts @@ -39,6 +39,7 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../pipes/format-number.pipe'; import { CurrencyPipe } from '@angular/common'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AuthenticationService } from '../../../core/authentication/authentication.service'; import { ReportsService } from 'app/reports/reports.service'; import { SettingsService } from 'app/settings/settings.service'; import { Subject } from 'rxjs'; @@ -151,6 +152,7 @@ export class GeneralTabComponent implements OnDestroy { private router = inject(Router); private reportsService = inject(ReportsService); private settingsService = inject(SettingsService); + private authenticationService = inject(AuthenticationService); /** Open Loan Accounts Columns */ openLoansColumns: string[] = [ @@ -177,6 +179,7 @@ export class GeneralTabComponent implements OnDestroy { openSavingsColumns: string[] = [ 'Account No', 'Saving Account', + 'Branch', 'Last Active', 'Balance', 'Actions' @@ -185,12 +188,14 @@ export class GeneralTabComponent implements OnDestroy { closedSavingsColumns: string[] = [ 'Account No', 'Saving Account', + 'Branch', 'Closed Date' ]; /** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */ recurringColumns: string[] = [ 'Account No', 'Saving Account', + 'Branch', 'Status', 'Last Active', 'Balance', @@ -336,12 +341,76 @@ export class GeneralTabComponent implements OnDestroy { /** Client Id */ clientid: any; + /** The customer's own (home) branch, to tell it from branches the customer banks at elsewhere. */ + clientOfficeId: number | null = null; + + /** How many red shades the branch chips have (general-tab.component.scss, .branch-chip--other-0 ... -3), light to dark. */ + private static readonly OTHER_BRANCH_SHADES = 4; + + /** The branch the signed-in user works at: accounts held here read green, every other branch red. */ + private get myOfficeId(): number | null { + const officeId = this.authenticationService.getCredentials()?.officeId; + return officeId == null ? null : Number(officeId); + } + + /** + * The other branches this customer banks at, lightest to darkest. Ordered by branch id so the shades stay put between + * visits; computed over every deposit account on the page so one branch has the same shade in every table. + */ + private otherBranchIds(): number[] { + const mine = this.myOfficeId; + const ids = new Set(); + for (const account of [ + ...this.savingAccounts, + ...this.currentAccounts + ]) { + const officeId = Number(account?.officeId); + if (Number.isFinite(officeId) && officeId !== mine) { + ids.add(officeId); + } + } + return [...ids].sort((a, b) => a - b); + } + + /** Green for an account at the signed-in user's branch; red for any other branch, a different shade for each. */ + branchChipClass(account: any): string { + const officeId = Number(account?.officeId); + if (officeId === this.myOfficeId) { + return 'branch-chip branch-chip--mine'; + } + const others = this.otherBranchIds(); + const rank = Math.max(others.indexOf(officeId), 0); + const last = GeneralTabComponent.OTHER_BRANCH_SHADES - 1; + // one other branch gets a mid red, several are spread from the lightest to the darkest + const shade = others.length <= 1 ? 1 : Math.round((rank * last) / (others.length - 1)); + return `branch-chip branch-chip--other-${shade}`; + } + + branchChipTitle(account: any): string { + const parts = [account?.officeName]; + if (Number(account?.officeId) === this.myOfficeId) { + parts.push('your branch'); + } + if (this.isHomeBranch(account)) { + parts.push('customer home branch'); + } + return parts.join(' - '); + } + + isHomeBranch(account: any): boolean { + return this.clientOfficeId != null && Number(account?.officeId) === this.clientOfficeId; + } + /** * @param {ActivatedRoute} route Activated Route * @param {ClientsService} clientService Clients Service * @param {Router} router Router */ constructor() { + // The customer's record is resolved on the parent route, not on this tab's own route data. + this.route.parent?.data.subscribe((data: any) => { + this.clientOfficeId = data?.clientViewData?.officeId ?? null; + }); this.route.data.subscribe( (data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => { this.clientAccountData = data.clientAccountsData; diff --git a/src/app/clients/clients.component.html b/src/app/clients/clients.component.html index 250023553..f56d8f9d0 100644 --- a/src/app/clients/clients.component.html +++ b/src/app/clients/clients.component.html @@ -50,6 +50,17 @@ /> + @if (isHeadOffice) { + + + All Branches + @for (office of offices; track office.id) { + {{ office.name }} + } + + + } + All Status @@ -92,6 +103,11 @@ row.legalForm?.id === 2 ? 'business' : 'person' }} {{ hideClientData ? maskName(row.displayName) : row.displayName }} + @if (!isHeadOffice && row.officeId && myOfficeId !== null && row.officeId !== myOfficeId) { + + Home: {{ row.officeName }} + + } @if (row._ckycDone) { verified } @@ -114,6 +130,12 @@
+ + + Branch + {{ row.officeName }} + + {{ 'labels.inputs.Status' | translate }} diff --git a/src/app/clients/clients.component.scss b/src/app/clients/clients.component.scss index b43c98496..ba9f068ab 100644 --- a/src/app/clients/clients.component.scss +++ b/src/app/clients/clients.component.scss @@ -7,3 +7,15 @@ */ @use '../shared/styles/list-layout' as *; + +// A customer registered at another branch that has been linked to this one. +.row-home-chip { + margin-left: 8px; + padding: 1px 8px; + font-size: 11px; + font-weight: 500; + color: #92400e; + background: #fef3c7; + border-radius: 999px; + white-space: nowrap; +} diff --git a/src/app/clients/clients.component.ts b/src/app/clients/clients.component.ts index d93a1ab2f..3291ab25e 100644 --- a/src/app/clients/clients.component.ts +++ b/src/app/clients/clients.component.ts @@ -38,6 +38,8 @@ import { MatIconButton, MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AuthenticationService } from '../core/authentication/authentication.service'; +import { OrganizationService } from '../organization/organization.service'; @Component({ selector: 'mifosx-clients', @@ -71,6 +73,15 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; }) export class ClientsComponent implements OnInit { private clientService = inject(ClientsService); + private authenticationService = inject(AuthenticationService); + private organizationService = inject(OrganizationService); + + /** Head Office sees every branch's clients, so it gets a Branch filter. A branch user only ever sees its own. */ + isHeadOffice = this.authenticationService.isHeadOfficeUser(); + offices: { id: number; name: string }[] = []; + officeFilter: number | null = null; + /** The caller's own branch, to tell a customer of another branch (linked here) from this branch's own. */ + myOfficeId = this.authenticationService.getCredentials()?.officeId ?? null; /** Returns true if client data masking is enabled */ get hideClientData(): boolean { @@ -90,6 +101,7 @@ export class ClientsComponent implements OnInit { 'displayName', 'cifNumber', 'legalForm', + 'officeName', 'status', 'profileCompletion', 'actions' @@ -119,6 +131,13 @@ export class ClientsComponent implements OnInit { @ViewChild(MatSort) sort: MatSort; ngOnInit() { + if (!this.isHeadOffice) { + this.displayedColumns = this.displayedColumns.filter((c) => c !== 'officeName'); + } else { + this.organizationService.getOffices().subscribe((offices: any[]) => { + this.offices = (offices || []).map((o) => ({ id: o.id, name: o.name })); + }); + } if (environment.preloadClients) { this.getClients(); } @@ -144,7 +163,8 @@ export class ClientsComponent implements OnInit { this.sortDirection, false, '', - true + true, + this.isHeadOffice ? this.officeFilter : null ) .subscribe( (data: any) => { @@ -233,6 +253,12 @@ export class ClientsComponent implements OnInit { this.dataSource.data = filtered; } + /** Branch filter is applied by the server so paging and the record count stay correct. */ + officeChanged() { + this.resetPaginator(); + this.getClients(); + } + pageChanged(event: PageEvent) { this.pageSize = event.pageSize; this.currentPage = event.pageIndex; diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index cd08181ce..d2acda582 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -18,6 +18,7 @@ import { map, catchError } from 'rxjs/operators'; import { environment } from '../../environments/environment'; import { dataUrlToFile } from 'app/shared/utils/data-url.util'; +import { ClientIdentityLookupResult } from './models/client-identity.model'; /** * Clients service. @@ -742,9 +743,14 @@ export class ClientsService { sortDirection: string = '', activeOnly: boolean = false, legalFormType: string = '', - silent: boolean = false + silent: boolean = false, + officeId: number | null = null ) { const innerRequest: any = { text }; + // Branch filter (Head Office list). The server still limits results to the caller's office hierarchy. + if (officeId != null) { + innerRequest.officeId = officeId; + } if (activeOnly) { innerRequest.activeOnly = true; } @@ -840,6 +846,11 @@ export class ClientsService { return this.http.post('/kyc/risk-rules', rule); } + /** Ends the trial of a newly added rule; it stays in the catalog as a normal rule. */ + markClientRiskRuleNormal(ruleId: number): Observable { + return this.http.post(`/kyc/risk-rules/${ruleId}/mark-normal`, {}); + } + /** Fetch code values by code ID */ getCodeValuesById(codeId: number): Observable { return this.http.get(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([]))); @@ -868,6 +879,39 @@ export class ClientsService { return this.http.post(`/clients/${clientId}/kyc`, kycData); } + /** + * "Is this person already a customer of the bank?" — across every branch, by PAN, Aadhaar fingerprint or GSTIN. + * Any ONE of them matching means the same customer. `contactConflicts` reports a phone/email already used by another + * client, which is never the same customer. Only the Aadhaar's SHA-256 is sent, never the number. + */ + /** + * Introduces a customer of another branch to the caller's branch, so the branch can see them and open an account + * for them there. The customer stays at the home branch. Safe to repeat. + */ + linkClientToMyBranch( + clientId: number + ): Observable<{ clientId: number; linked: boolean; officeId: number; officeName: string }> { + return this.http.post<{ clientId: number; linked: boolean; officeId: number; officeName: string }>( + '/clientidentity/link', + { + clientId + } + ); + } + + lookupClientIdentity(identity: { + panNumber?: string | null; + aadhaarHash?: string | null; + gstinNumber?: string | null; + mobileNo?: string | null; + emailAddress?: string | null; + excludeClientId?: number | null; + }): Observable { + return this.http.post('/clientidentity/lookup', identity, { + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + /** * Screen a client name against the UN Security Council consolidated sanctions list. * @param name The name to screen @@ -1073,9 +1117,91 @@ export class ClientsService { */ saveKycContactLog( clientId: string | number, - data: { channel: string; notes: string; officerName?: string } + data: { channel: string; notes: string; officerName?: string; noticeType?: string }, + silent = false ): Observable { - return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data); + return this.http.post( + `/kyc/exceptions/${clientId}/contact-log`, + data, + silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {} + ); + } + + /** + * The subject and text a customer would receive for the stage they are at today. + * `type` is KYC or DORMANCY; the body comes back empty when they are at no such stage. + */ + previewKycNotice(clientId: string | number, type: string): Observable<{ subject?: string; body?: string }> { + return this.http.get<{ subject?: string; body?: string }>( + `/kyc/exceptions/${clientId}/contact-log/preview?type=${type}`, + { + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + } + ); + } + + /** Queues notices to be sent in the background by the queue job. */ + enqueueKycNotices(body: { + items: { clientId: number; noticeType: string; channel: string; accountNo?: string; stageLabel?: string }[]; + notes?: string; + officerName?: string; + sendAt?: string; + }): Observable<{ queued: number; skipped: number }> { + return this.http.post<{ queued: number; skipped: number }>('/kyc/notice-queue', body); + } + + /** Queued notices, newest first; status narrows to PENDING, SENT, FAILED or CANCELLED. */ + getKycNoticeQueue(status = ''): Observable { + return this.http.get(`/kyc/notice-queue${status ? `?status=${status}` : ''}`, { + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + + actOnKycNotice(id: number, command: 'cancel' | 'retry'): Observable { + return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {}); + } + + /** Notices per day (customers, notices, outcomes), newest day first. */ + getKycContactAuditDays( + filter: { from?: string; to?: string; channel?: string; status?: string; search?: string } = {} + ): Observable { + return this.http.get('/kyc/contact-audit/days', { + params: this.auditParams(filter), + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + + /** Customer notices on record, newest first, a page at a time. */ + getKycContactAudit( + filter: { + from?: string; + to?: string; + channel?: string; + status?: string; + search?: string; + offset?: number; + limit?: number; + } = {} + ): Observable<{ total: number; rows: any[] }> { + return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit', { + params: this.auditParams(filter), + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + + private auditParams(filter: Record): HttpParams { + let params = new HttpParams(); + Object.entries(filter).forEach( + ([ + k, + v + ]) => { + if (v !== undefined && v !== null && v !== '') { + params = params.set(k, String(v)); + } + } + ); + return params; } getKycContactLog(clientId: string | number): Observable { @@ -1092,6 +1218,14 @@ export class ClientsService { return this.http.get('/kyc/exceptions/lapsed'); } + /** Clients whose re-KYC falls due within the next `days` days (default 30), soonest first. */ + getExpiringKyc(days = 30): Observable { + // The caller reports a failure in its own banner, so the global popup is suppressed. + return this.http.get(`/kyc/exceptions/expiring?days=${days}`, { + context: new HttpContext().set(SILENT_HTTP_ERROR, true) + }); + } + /** * Savings accounts the dormancy job has marked. * diff --git a/src/app/clients/create-client/create-client.component.html b/src/app/clients/create-client/create-client.component.html index 039ada14f..b6937e631 100644 --- a/src/app/clients/create-client/create-client.component.html +++ b/src/app/clients/create-client/create-client.component.html @@ -38,6 +38,12 @@ }
} + @if (!createdClientId) { + + } diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index ccd05c057..723fbffd6 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -23,6 +23,10 @@ import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india- import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component'; import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component'; import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component'; +import { + ClientIdentityCheckComponent, + ClientIdentityState +} from '../client-stepper/client-identity-check/client-identity-check.component'; import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component'; import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component'; @@ -60,6 +64,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; ClientEntityKycStepComponent, ClientDatatableStepComponent, ClientKycStepComponent, + ClientIdentityCheckComponent, ClientPreviewStepComponent, ClientFamilyMembersStepComponent, MatProgressBarModule, @@ -79,6 +84,7 @@ export class CreateClientComponent { @ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent; @ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent; @ViewChild('clientKyc') clientKycStep: ClientKycStepComponent; + @ViewChild(ClientIdentityCheckComponent) identityCheck: ClientIdentityCheckComponent; @ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent; @ViewChildren('dtclient') clientDatatables: QueryList; @@ -87,6 +93,17 @@ export class CreateClientComponent { selectedTabIndex = 0; createdClientId = ''; savingClient = false; + /** + * Result of the existing-customer check at the top of the General step. While it says the customer already exists + * (or the identifiers disagree) a new client must not be created; otherwise the identifiers typed there are filled + * into the KYC step. + */ + identity: ClientIdentityState | null = null; + + onIdentityChange(state: ClientIdentityState): void { + this.identity = state; + } + /** Pre-fill data passed from "Create as Client" on a manual family member */ prefillData: any = null; @@ -138,6 +155,8 @@ export class CreateClientComponent { this.highestTabReached = 0; this.validationError = ''; this.prefillData = null; + this.identity = null; + this.identityCheck?.reset(); this.clientGeneralStep?.resetForNewClient(); this.clientKycStep?.resetForNewClient(); this.clientIndiaAddressStep?.resetForNewClient(); @@ -212,6 +231,10 @@ export class CreateClientComponent { const idx = this.selectedTabIndex; if (idx === 0) { + // The customer is already on the books (any branch), or the identifiers belong to different customers. + if (!this.createdClientId && (this.identity?.blocking || this.identity?.status === 'checking')) return false; + // A PAN / Aadhaar / GSTIN typed in the wrong format is neither looked up nor carried on, so it must be fixed or cleared. + if (!this.createdClientId && this.identityCheck?.hasInvalidInput) return false; if (!this.clientGeneralStep || !this.clientGeneralForm) return false; if (this.clientGeneralStep.sanctionsBlocked) return false; // A number the officer rejected on the DIP result must be replaced, not carried forward. @@ -274,6 +297,17 @@ export class CreateClientComponent { if (this.savingClient) return 'Saving — please wait…'; if (this.currentStepValid) return ''; const idx = this.selectedTabIndex; + if (idx === 0 && !this.createdClientId && this.identityCheck?.hasInvalidInput) { + return 'Correct the PAN / Aadhaar / GSTIN format in the Existing customer check, or clear the field.'; + } + if (idx === 0 && !this.createdClientId && this.identity?.status === 'checking') { + return 'Checking whether this customer already exists…'; + } + if (idx === 0 && !this.createdClientId && this.identity?.blocking) { + return this.identity.status === 'conflict' + ? 'The PAN / Aadhaar / GSTIN entered belong to different customers — correct them or ask a manager.' + : 'This customer already exists — a new client is not created for the same person. Open the existing customer instead.'; + } if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) { const disposition = this.clientGeneralStep.sanctionsDisposition; if (disposition === 'FALSE_POSITIVE') { @@ -358,6 +392,19 @@ export class CreateClientComponent { onNext() { // If on General tab and client not yet created — save first if (this.selectedTabIndex === 0 && !this.createdClientId) { + // Defensive re-check: the button is disabled while this holds, but a second client for the same person is the + // one mistake this screen exists to prevent. + if (this.identityCheck?.hasInvalidInput) { + this.identityCheck.markAllAsTouched(); + this.snackBar.open(this.currentStepBlockerMessage, 'Close', { duration: 6000 }); + return; + } + if (this.identity?.blocking) { + this.snackBar.open(this.currentStepBlockerMessage || 'This customer already exists.', 'Close', { + duration: 6000 + }); + return; + } if (!this.clientGeneralForm?.valid) { this.clientGeneralForm?.markAllAsTouched(); setTimeout(() => { diff --git a/src/app/clients/models/client-identity.model.ts b/src/app/clients/models/client-identity.model.ts new file mode 100644 index 000000000..e29ff8e54 --- /dev/null +++ b/src/app/clients/models/client-identity.model.ts @@ -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/. + */ + +/** An existing customer found by PAN / Aadhaar fingerprint / GSTIN. Identifiers are masked by the server. */ +export interface ClientIdentityMatch { + clientId: number; + displayName: string; + cifNumber: string; + officeId: number; + officeName: string; + legalFormEnum: number | null; + kycStatus: number; + /** True when the caller's branch can open this client; false when it sits in another branch. */ + visibleToCaller: boolean; + panMasked: string | null; + aadhaarMasked: string | null; + gstinMasked: string | null; + matchedBy: Array<'PAN' | 'AADHAAR' | 'GSTIN'>; +} + +export interface ClientIdentityLookupResult { + /** Any ONE of PAN / Aadhaar / GSTIN matched an existing customer: the same person or entity. */ + matched: boolean; + /** The identifiers point at different customers: bad data or fraud, not a customer to reuse. */ + conflict: boolean; + matches: ClientIdentityMatch[]; + /** A phone or email already used by another client. Never means "the same customer". */ + contactConflicts: { mobileNo: boolean; emailAddress: boolean }; +} diff --git a/src/app/clients/utils/aadhaar-hash.util.spec.ts b/src/app/clients/utils/aadhaar-hash.util.spec.ts new file mode 100644 index 000000000..48534e8df --- /dev/null +++ b/src/app/clients/utils/aadhaar-hash.util.spec.ts @@ -0,0 +1,52 @@ +/** + * 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 { createHash } from 'crypto'; +import { aadhaarHash, sha256Hex } from './aadhaar-hash.util'; + +describe('sha256Hex', () => { + it('matches the published SHA-256 test vectors', () => { + expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + expect(sha256Hex('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')).toBe( + '248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1' + ); + }); + + it('agrees with Node on 12-digit inputs, including ones that need a second block boundary', () => { + for (const value of [ + '000000000000', + '123456789012', + '999999999999', + '0'.repeat(55), + '0'.repeat(56), + '0'.repeat(64) + ]) { + expect(sha256Hex(value)).toBe(createHash('sha256').update(value).digest('hex')); + } + }); +}); + +describe('aadhaarHash', () => { + it('hashes a 12-digit number', () => { + expect(aadhaarHash('123456789012')).toBe(createHash('sha256').update('123456789012').digest('hex')); + }); + + it('ignores the spaces and hyphens people type between the groups', () => { + expect(aadhaarHash('1234 5678 9012')).toBe(aadhaarHash('123456789012')); + expect(aadhaarHash('1234-5678-9012')).toBe(aadhaarHash('123456789012')); + }); + + it('returns null for anything that is not a full number', () => { + expect(aadhaarHash('')).toBeNull(); + expect(aadhaarHash(null)).toBeNull(); + expect(aadhaarHash('XXXXXXXX1234')).toBeNull(); + expect(aadhaarHash('XXXXXXXX0000')).toBeNull(); + expect(aadhaarHash('12345678901')).toBeNull(); + }); +}); diff --git a/src/app/clients/utils/aadhaar-hash.util.ts b/src/app/clients/utils/aadhaar-hash.util.ts new file mode 100644 index 000000000..a3bd2fef7 --- /dev/null +++ b/src/app/clients/utils/aadhaar-hash.util.ts @@ -0,0 +1,185 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * The comparable fingerprint of an Aadhaar number: SHA-256 of the 12 digits, as 64 lowercase hex characters. + * + * Only this fingerprint is sent to the server. The KYC create/update calls are recorded in an audit table and a log + * on the server, so the 12-digit number must not be in the request. + * + * A plain, unkeyed SHA-256 of 12 digits can be reversed by trying every number, so this is a lookup key and not + * protection against someone who can read the stored value. + * + * Written out in full instead of using `crypto.subtle` because that API is asynchronous and only exists in secure + * contexts, while the callers (a form-value getter, a blur handler) need the answer synchronously and the app may be + * served over plain HTTP. + */ + +const K = [ + 0x428a2f98, + 0x71374491, + 0xb5c0fbcf, + 0xe9b5dba5, + 0x3956c25b, + 0x59f111f1, + 0x923f82a4, + 0xab1c5ed5, + 0xd807aa98, + 0x12835b01, + 0x243185be, + 0x550c7dc3, + 0x72be5d74, + 0x80deb1fe, + 0x9bdc06a7, + 0xc19bf174, + 0xe49b69c1, + 0xefbe4786, + 0x0fc19dc6, + 0x240ca1cc, + 0x2de92c6f, + 0x4a7484aa, + 0x5cb0a9dc, + 0x76f988da, + 0x983e5152, + 0xa831c66d, + 0xb00327c8, + 0xbf597fc7, + 0xc6e00bf3, + 0xd5a79147, + 0x06ca6351, + 0x14292967, + 0x27b70a85, + 0x2e1b2138, + 0x4d2c6dfc, + 0x53380d13, + 0x650a7354, + 0x766a0abb, + 0x81c2c92e, + 0x92722c85, + 0xa2bfe8a1, + 0xa81a664b, + 0xc24b8b70, + 0xc76c51a3, + 0xd192e819, + 0xd6990624, + 0xf40e3585, + 0x106aa070, + 0x19a4c116, + 0x1e376c08, + 0x2748774c, + 0x34b0bcb5, + 0x391c0cb3, + 0x4ed8aa4a, + 0x5b9cca4f, + 0x682e6ff3, + 0x748f82ee, + 0x78a5636f, + 0x84c87814, + 0x8cc70208, + 0x90befffa, + 0xa4506ceb, + 0xbef9a3f7, + 0xc67178f2 +]; + +function rotr(x: number, n: number): number { + return (x >>> n) | (x << (32 - n)); +} + +/** SHA-256 of a string (UTF-8 encoded), as lowercase hex. */ +export function sha256Hex(text: string): string { + const bytes: number[] = []; + for (const ch of unescape(encodeURIComponent(text))) { + bytes.push(ch.charCodeAt(0)); + } + const bitLength = bytes.length * 8; + + bytes.push(0x80); + while (bytes.length % 64 !== 56) { + bytes.push(0); + } + // 64-bit big-endian message length; inputs here are tiny so the high word is always 0. + const high = Math.floor(bitLength / 0x100000000); + const low = bitLength >>> 0; + for (const word of [ + high, + low + ]) { + bytes.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff); + } + + const h = [ + 0x6a09e667, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19 + ]; + const w = new Array(64); + + for (let offset = 0; offset < bytes.length; offset += 64) { + for (let i = 0; i < 16; i++) { + const j = offset + i * 4; + w[i] = (bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3]; + } + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + + let [ + a, + b, + c, + d, + e, + f, + g, + hh + ] = h; + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K[i] + w[i]) | 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) | 0; + hh = g; + g = f; + f = e; + e = (d + t1) | 0; + d = c; + c = b; + b = a; + a = (t1 + t2) | 0; + } + h[0] = (h[0] + a) | 0; + h[1] = (h[1] + b) | 0; + h[2] = (h[2] + c) | 0; + h[3] = (h[3] + d) | 0; + h[4] = (h[4] + e) | 0; + h[5] = (h[5] + f) | 0; + h[6] = (h[6] + g) | 0; + h[7] = (h[7] + hh) | 0; + } + + return h.map((x) => (x >>> 0).toString(16).padStart(8, '0')).join(''); +} + +/** + * The fingerprint for a 12-digit Aadhaar, or null when the value is not one (too short, masked, a placeholder). + * Spaces and hyphens people type between the groups are ignored. + */ +export function aadhaarHash(aadhaar: string | null | undefined): string | null { + const digits = (aadhaar ?? '').replace(/[\s-]/g, ''); + return /^\d{12}$/.test(digits) ? sha256Hex(digits) : null; +} diff --git a/src/app/clients/utils/name-case.util.spec.ts b/src/app/clients/utils/name-case.util.spec.ts index e58b929b0..c3e15a842 100644 --- a/src/app/clients/utils/name-case.util.spec.ts +++ b/src/app/clients/utils/name-case.util.spec.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { toProperNameCase } from './name-case.util'; +import { toPersonNameCase, toProperNameCase } from './name-case.util'; describe('toProperNameCase', () => { it('raises a lower-case first letter on every word', () => { @@ -53,3 +53,20 @@ describe('toProperNameCase', () => { expect(toProperNameCase(undefined)).toBe(''); }); }); + +describe('toPersonNameCase', () => { + it('title-cases every word, lowering mixed or upper case', () => { + expect(toPersonNameCase('keshav SIngh sisodiya')).toBe('Keshav Singh Sisodiya'); + expect(toPersonNameCase('KESHAV')).toBe('Keshav'); + }); + + it('keeps hyphen and apostrophe name prefixes', () => { + expect(toPersonNameCase('JEAN-marie')).toBe('Jean-Marie'); + expect(toPersonNameCase("D'SOUZA")).toBe("D'Souza"); + }); + + it('returns an empty string for blank or non-string input', () => { + expect(toPersonNameCase(' ')).toBe(''); + expect(toPersonNameCase(null)).toBe(''); + }); +}); diff --git a/src/app/clients/utils/name-case.util.ts b/src/app/clients/utils/name-case.util.ts index f127ff906..7ac74f7b4 100644 --- a/src/app/clients/utils/name-case.util.ts +++ b/src/app/clients/utils/name-case.util.ts @@ -62,3 +62,17 @@ export function toProperNameCase(value: string | null | undefined): string { } return collapsed.split(' ').map(capitalizeWord).join(' '); } + +/** + * Title case for an individual's first, middle and last name: each word's first letter raised, + * the rest lowered, so 'SIngh' and 'singh' both become 'Singh'. This is what the Full Name line on + * the general step has always shown; using it for the stored value too keeps the preview and the + * saved client the same as that line. Entity names stay on {@link toProperNameCase} — their + * spelling is matched against the registered PAN name. + */ +export function toPersonNameCase(value: string | null | undefined): string { + if (typeof value !== 'string') { + return ''; + } + return toProperNameCase(value.toLowerCase()); +} diff --git a/src/app/core/http/cache.interceptor.ts b/src/app/core/http/cache.interceptor.ts index d19b669c7..e5c8ed2b0 100644 --- a/src/app/core/http/cache.interceptor.ts +++ b/src/app/core/http/cache.interceptor.ts @@ -35,6 +35,7 @@ export class CacheInterceptor implements HttpInterceptor { '/rbireservereturn', '/rbireserveconfig', '/tdsconfig', + '/profitappropriationconfig', '/tds/deposit-interest', // Declarations and challans are entered on the very screens that read them back. '/tds/declarations', @@ -53,7 +54,12 @@ export class CacheInterceptor implements HttpInterceptor { // deposit may be closed at all - a stale copy would show a released lien as still blocking. '/liens', // The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken. - '/recurringdepositaccounts/dashboard' + '/recurringdepositaccounts/dashboard', + // Notes are also written by action screens (approve, close, deposit...), so a cached list would hide them. + '/notes', + // The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's + // own bucket edges from the query; a cached copy would show another date's or another setting's figures. + '/almreturn' ]; private isCacheable(url: string): boolean { diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index dfa7bfad3..f35b0daa2 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1246,6 +1246,9 @@ + @@ -2055,11 +2058,11 @@ - ALM (Asset Liability - Management) + ALM (Average Liabilities + and Assets) – Time Bucket Breakdown Balance Sheet (BR Act & Others) diff --git a/src/app/core/shell/shell.component.ts b/src/app/core/shell/shell.component.ts index 47a81d008..56d01a925 100644 --- a/src/app/core/shell/shell.component.ts +++ b/src/app/core/shell/shell.component.ts @@ -364,6 +364,25 @@ export class ShellComponent implements OnInit, OnDestroy { }); } + /** + * Opens the ALM Time-Bucket Breakdown: which balance-sheet item goes to which bucket of the + * Structural Liquidity Statement. Rules are editable and kept per browser. + */ + async openAlmBucketBreakdownDialog(): Promise { + const { AlmBucketBreakdownDialogComponent } = + await import('../../home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component'); + this.dialog.open(AlmBucketBreakdownDialogComponent, { + width: '94vw', + maxWidth: '1320px', + maxHeight: '92vh', + panelClass: 'crr-slr-dialog-panel', + autoFocus: false, + disableClose: false, + hasBackdrop: true, + backdropClass: 'alm-dlg-backdrop' + }); + } + /** * Unsubscribes from progress bar. */ diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html index 69528ae98..fa708df8b 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html @@ -13,6 +13,18 @@

Account Details

+ @if (isCcRequest) { +

+ @if (ccProductsMissing) { + No CC product yet. A Cash Credit account is opened on a current product that allows an + overdraft against a sanctioned limit. Create or edit a current product with “Is Overdraft Allowed” switched + on, then come back here. + } @else { + Opening a Cash Credit (CC) account: only products that allow an overdraft are listed. The + sanctioned limit comes from the product. + } +

+ }
{{ 'labels.inputs.Product Name' | translate }} @@ -838,9 +850,23 @@ {{ c.clientCif || '—' }} - - {{ c.sharePercentage }}% - + @if (readOnly) { + + {{ c.sharePercentage }}% + + } @else { + + % + + } + + } @empty { - No CTR reports generated yet. + No CTR reports generated yet. }
+ + + +
+ @if (ctrReportView; as report) { +
+
+

CTR report — {{ report.reportMonth }}

+

+ {{ report.status }} · {{ ctrReportLines.length }} record{{ ctrReportLines.length === 1 ? '' : 's' }} · + total ₹{{ ctrReportTotal | number: '1.2-2' }} +

+
+
+ + +
+
+ @if (ctrReportLinesLoading) { +
Loading the customers in this report…
+ } @else { +
+ + + + + + + + + + + + + + + @for (l of ctrReportLines; track $index) { + + + + + + + + + + + } @empty { + + + + } + +
CustomerCustomer ID / CKYCAccountBranchDateDirectionTypeAmount
+ {{ l.clientName }} +
+ {{ l.individual === false ? 'Entity' : 'Individual' }} +
+
{{ l.ckycNumber || l.clientExternalId || '—' }}{{ l.accountNo || '—' }}{{ l.branchName || '—' }}{{ l.transactionDate ? (asDate(l.transactionDate) | date: 'dd MMM yyyy') : '—' }}{{ l.direction || '—' }}{{ l.connectedGroup ? 'Connected group (month total)' : 'Single transaction' }}₹{{ l.amount | number: '1.2-2' }}
This report names no customers.
+
+ } + } +
+
} @@ -581,146 +666,153 @@ } - @if (selectedStrCase) { -
-
-
-

STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}

-

- Alert #{{ selectedStrCase.alertId }} · - {{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client - @if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) { - Risk category auto-escalated + + +

+ @if (selectedStrCase) { +
+
+
+

STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}

+

+ Alert #{{ selectedStrCase.alertId }} · + {{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client + @if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) { + Risk category auto-escalated + } +

+
+ +
+ +
+ {{ selectedStrCase.status }} + {{ strCaseOutcome(selectedStrCase) }} +
+ +
+
+ + +
+
+ + +
+ +
+
+ Principal Officer verification — mark each point as found + + {{ answeredCount }} / {{ strChecklist.length }} answered + +
+ + + + + + + + + + @for (item of strChecklist; track item.key) { + + + + + + } + +
Verification pointGenuineNot satisfied
+
{{ item.label }}
+
{{ item.hint }}
+
+ + + +
+
+ + +
+ @if (!allAnswered) { + + + Answer every verification point to determine the outcome — + {{ strChecklist.length - answeredCount }} still pending. + + } @else if (derivedConclusion === 'NOT_SUSPICIOUS') { + + + Not suspicious. All {{ strChecklist.length }} points found genuine — the case + will be closed and nothing filed with FIU-IND. + + } @else { + + + Suspicious — STR must be filed. + {{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied: + {{ failedFindings[0].label }} + @if (failedFindings.length > 1) { + and {{ failedFindings.length - 1 }} more + } + . + + } +
+ + + + @if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') { +
+ + + +
} -

-
- -
- -
- {{ selectedStrCase.status }} - {{ strCaseOutcome(selectedStrCase) }} -
- -
-
- - -
-
- - -
- -
-
- Principal Officer verification — mark each point as found - - {{ answeredCount }} / {{ strChecklist.length }} answered -
- - - - - - - - - - @for (item of strChecklist; track item.key) { - - - - - - } - -
Verification pointGenuineNot satisfied
-
{{ item.label }}
-
{{ item.hint }}
-
- - - -
- - -
- @if (!allAnswered) { - - - Answer every verification point to determine the outcome — - {{ strChecklist.length - answeredCount }} still pending. - - } @else if (derivedConclusion === 'NOT_SUSPICIOUS') { - - - Not suspicious. All {{ strChecklist.length }} points found genuine — the case will be - closed and nothing filed with FIU-IND. - - } @else { - - - Suspicious — STR must be filed. - {{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied: - {{ failedFindings[0].label }} - @if (failedFindings.length > 1) { - and {{ failedFindings.length - 1 }} more - } - . - - } -
- - - - @if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') { -
- - - -
- } -
+ }
- } +
diff --git a/src/app/fiu/fiu.component.scss b/src/app/fiu/fiu.component.scss index f8f0390c8..bef42a0dc 100644 --- a/src/app/fiu/fiu.component.scss +++ b/src/app/fiu/fiu.component.scss @@ -6,7 +6,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -:host { +:host, +.fiu-dialog { --f-navy: #0b1e3d; --f-blue: #1d4ed8; --f-blue-l: #eff6ff; @@ -1858,3 +1859,8 @@ white-space: normal; font-size: 12.5px; } + +/* The STR case popup renders in the overlay, outside :host, so it carries the page's colour variables itself. */ +.fiu-dialog { + padding: 4px; +} diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index e918bce81..3b20699df 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -6,7 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject, ViewChild, TemplateRef } from '@angular/core'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { forkJoin, of } from 'rxjs'; +import { exportWorkbook } from 'app/cashier/cashier-export'; +import { catchError } from 'rxjs/operators'; import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -131,6 +136,134 @@ export class FiuComponent implements OnInit { private sanctionsLogsService = inject(SanctionsLogsService); private authenticationService = inject(AuthenticationService); private route = inject(ActivatedRoute); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + /** The STR case investigation, shown in a popup rather than a panel pushed in under the case list. */ + @ViewChild('strCaseDialog') strCaseDialogTpl!: TemplateRef; + private strCaseDialogRef: MatDialogRef | null = null; + + /** The CTR report whose customers are open in the popup, and the lines behind it. */ + @ViewChild('ctrReportDialog') ctrReportDialogTpl!: TemplateRef; + private ctrReportDialogRef: MatDialogRef | null = null; + ctrReportView: any = null; + ctrReportLines: any[] = []; + ctrReportLinesLoading = false; + + /** Opens a generated CTR report with every customer and transaction it names. */ + openCtrReport(report: any): void { + this.ctrReportView = report; + this.ctrReportLines = []; + this.ctrReportLinesLoading = true; + this.amlService.getCtrReportLines(report.id).subscribe({ + next: (lines) => { + this.ctrReportLines = lines || []; + this.ctrReportLinesLoading = false; + }, + error: () => { + this.ctrReportLinesLoading = false; + this.toast(`Could not load the customers in the ${report.reportMonth} report`); + } + }); + this.ctrReportDialogRef?.close(); + this.ctrReportDialogRef = this.dialog.open(this.ctrReportDialogTpl, { + width: '1280px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false + }); + this.ctrReportDialogRef.afterClosed().subscribe(() => { + this.ctrReportView = null; + this.ctrReportDialogRef = null; + }); + } + + closeCtrReport(): void { + this.ctrReportDialogRef?.close(); + } + + /** Total of the amounts in the open report, so the popup states what the filing adds up to. */ + get ctrReportTotal(): number { + return this.ctrReportLines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0); + } + + /** + * Excel workbook of a CTR report with the customer behind every line. Fetches the lines itself unless the popup + * already holds them, so it works straight from the reports table as well as from inside the popup. + */ + exportCtrReport(report: any): void { + const build = (lines: any[]) => this.buildCtrReportWorkbook(report, lines); + if (this.ctrReportView?.id === report.id && !this.ctrReportLinesLoading) { + build(this.ctrReportLines); + return; + } + this.amlService.getCtrReportLines(report.id).subscribe({ + next: (lines) => build(lines || []), + error: () => this.toast(`Could not load the ${report.reportMonth} report to export it`) + }); + } + + private async buildCtrReportWorkbook(report: any, lines: any[]): Promise { + const total = lines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0); + await exportWorkbook( + [ + { + sheetName: `CTR ${report.reportMonth}`, + title: `Cash Transaction Report — ${report.reportMonth}`, + subtitle: `${report.status} · ${lines.length} record${lines.length === 1 ? '' : 's'} · total ₹${total.toLocaleString('en-IN')} · generated ${new Date().toLocaleString('en-IN')}`, + headers: [ + 'Report Month', + 'Customer', + 'Customer ID / CKYC Ref', + 'CKYC Number', + 'Customer Type', + 'Account No', + 'Branch', + 'Account Status', + 'Account Opened', + 'Transaction ID', + 'Transaction Date', + 'Direction', + 'Line Type', + 'Amount (₹)' + ], + rows: lines.map((l) => [ + l.reportMonth || report.reportMonth, + l.clientName || '', + l.clientExternalId || '', + l.ckycNumber || '', + l.individual === false ? 'Entity' : 'Individual', + l.accountNo || '', + l.branchName || '', + l.accountStatus || '', + toIsoDate(l.accountOpenedOn) || '', + l.transactionId ?? null, + toIsoDate(l.transactionDate) || '', + l.direction || '', + l.connectedGroup ? 'Connected group — month total' : 'Single transaction', + Number(l.amount) || 0 + ]), + widths: [ + 12, + 28, + 22, + 18, + 14, + 20, + 18, + 16, + 16, + 14, + 16, + 12, + 28, + 18 + ] + } + ], + `CTR-${report.reportMonth}` + ); + } /** Active tab */ activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard'; @@ -145,6 +278,8 @@ export class FiuComponent implements OnInit { */ ctrTxns: CtrTransaction[] = []; ctrLinesLoading = false; + /** Reportable cash transactions already posted but not yet in a generated CTR, listed automatically. */ + ctrPendingCount = 0; ctrLinesError = ''; /** * ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2. @@ -682,10 +817,23 @@ export class FiuComponent implements OnInit { loadCtrLines(): void { this.ctrLinesLoading = true; this.ctrLinesError = ''; - this.amlService.getCtrReportLines().subscribe({ - next: (lines) => { + // The pending list is an extra: if it cannot be read, the generated lines still show. + forkJoin({ + generated: this.amlService.getCtrReportLines(), + pending: this.amlService.getCtrPendingLines().pipe(catchError(() => of([] as any[]))) + }).subscribe({ + next: ({ generated, pending }) => { this.ctrLinesLoading = false; - this.ctrTxns = (lines || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1)); + const generatedRows = (generated || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1)); + const pendingRows = (pending || []).map((l: any, i: number) => ({ + ...this.toCtrTransaction(l, generatedRows.length + i + 1), + pendingFiling: true + })); + this.ctrPendingCount = pendingRows.length; + this.ctrTxns = [ + ...generatedRows, + ...pendingRows + ]; // Cached so the compliance flags an officer sets below survive a reload, keyed on the rows // that actually came back rather than on a seeded set. this.applyStoredCtrCompliance(); @@ -693,6 +841,7 @@ export class FiuComponent implements OnInit { error: (err: any) => { this.ctrLinesLoading = false; this.ctrTxns = []; + this.ctrPendingCount = 0; this.ctrLinesError = err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.'; @@ -820,6 +969,17 @@ export class FiuComponent implements OnInit { this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || ''; this.fileReferenceNo = strCase.strReferenceNo || ''; this.resetChecklist(); + this.strCaseDialogRef?.close(); + this.strCaseDialogRef = this.dialog.open(this.strCaseDialogTpl, { + width: '1100px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false + }); + this.strCaseDialogRef.afterClosed().subscribe(() => { + this.selectedStrCase = null; + this.strCaseDialogRef = null; + }); } /** Plain-language outcome for a live STR case, shown in the investigation panel. */ @@ -837,6 +997,7 @@ export class FiuComponent implements OnInit { } closeStrCaseDetail(): void { + this.strCaseDialogRef?.close(); this.selectedStrCase = null; } @@ -871,8 +1032,9 @@ export class FiuComponent implements OnInit { ? `STR case #${updated.id} concluded suspicious — file with FIU-IND by ${updated.fiuDueDate}` : `STR case #${updated.id} concluded not suspicious — no filing required` ); - this.selectedStrCase = updated; this.loadAmlStrCases(); + // Concluded: close the popup so the case's new status is what the officer sees in the list. + this.closeStrCaseDetail(); }, error: this.apiError(`Concluding STR case #${this.selectedStrCase.id}`) }); @@ -889,8 +1051,8 @@ export class FiuComponent implements OnInit { this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe({ next: (updated) => { this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`); - this.selectedStrCase = updated; this.loadAmlStrCases(); + this.closeStrCaseDetail(); }, error: this.apiError(`Filing STR case #${this.selectedStrCase.id}`) }); @@ -1784,6 +1946,12 @@ export class FiuComponent implements OnInit { } toast(msg: string): void { + // The page's own toast sits behind the popup's backdrop, so while the STR case popup is open the message would be + // there but unseen - which read as the action having done nothing. A snackbar is drawn above the popup. + if (this.strCaseDialogRef || this.ctrReportDialogRef) { + this.snackBar.open(msg, 'OK', { duration: 5000 }); + return; + } this.toastMsg = msg; this.toastVisible = true; if (this.toastTimer) clearTimeout(this.toastTimer); diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.html b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.html new file mode 100644 index 000000000..4a8fec04d --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.html @@ -0,0 +1,852 @@ + + +
+
+
+
+
+

ALM Time-Bucket Breakdown

+

Day-wise breakdown of FD / RD / Loan from the as-on date

+
+
+
+ @if (view === 'config') { + + + } + +
+
+ +
+ + +
+ +
+ @if (view === 'config') { +

+   Each line shows which bucket its money goes to. You can change the + % and the rule. Changes are saved in this browser straight away. Hover over a line name to see + why it is slotted that way. +

+ + @if (problemCount > 0) { +

+ {{ problemCount }} line(s) have a % total that is not 100 or is not filled in yet. See the red or yellow + totals below. +

+ } + } + + @if (view === 'config') { + +
+ + +
+ @if (loading) { + Loading data from accounts… + } @else if (loadError) { + {{ loadError }} + } @else if (breakdown) { + FD, RD and Loan data as on {{ breakdown.asOnDate }}. + } +
+
+ } + + @if (view === 'days') { +
+

1. Select

+
+ + +
+ + @if (selectBy === 'types') { +

Tick one or more. You can pick from both sides together.

+ @for (grp of pickGroups; track grp.side) { +
+
+ {{ grp.title }} +
+
+ @for (t of grp.items; track t.value) { + + } +
+
+ } +
+ + +
+ } @else { + + + @if (searching) { +

Searching…

+ } @else if (searchError) { +

{{ searchError }}

+ } @else if (searchText.trim().length < 2) { +

Type at least 2 characters of an account number or a customer name.

+ } @else if (!searchResults.length) { +

No accounts found.

+ } + + @if (searchResults.length) { +
+ @for (hit of searchResults; track hit.kind + '-' + hit.id) { + + } +
+ } + + @if (picked.length) { +
+ Selected ({{ picked.length }}) + @for (p of picked; track p.kind + '-' + p.id) { + + {{ p.kind }} {{ p.accountNo }} + + + } + +
+ } + } + +

2. Date and unit

+
+ + + +
+ + @if (reportShown && selectBy === 'types') { +
+

+ {{ typeColumns.length }} account {{ typeColumns.length === 1 ? 'type' : 'types' }} + as on {{ asOnIso }} · Amounts in {{ unitWord }} +

+ @if (loading) { +

Loading data from accounts…

+ } + @if (loadError) { +

{{ loadError }}

+ } + @if (breakdown) { + @for (c of typeColumns; track c.value) { + @if (c.notice) { +

{{ c.label }}: {{ c.notice }}

+ } + } +
+ + + + + + + @for (c of typeColumns; track c.value) { + + } + @if (typeHasLiability && typeHasAsset) { + + + } @else if (typeColumns.length > 1) { + + } + + + + @for (r of typeMatrix; track r.label) { + + + + + @for (v of r.cells; track $index) { + + } + @if (typeHasLiability && typeHasAsset) { + + + } @else if (typeColumns.length > 1) { + + } + + } + + + + + @for (c of typeColumns; track c.value) { + + } + @if (typeHasLiability && typeHasAsset) { + + + } @else if (typeColumns.length > 1) { + + } + + +
BucketDaysDates + {{ c.label }} + Liabilities totalAssets totalTotal
{{ r.label }}{{ r.days }}{{ r.dates }}{{ amountText(v) }}{{ amountText(r.liab) }}{{ amountText(r.asset) }}{{ amountText(r.liab + r.asset) }}
Total{{ amountText(c.total) }}{{ amountText(liabilityGrandTotal) }}{{ amountText(assetGrandTotal) }}{{ amountText(liabilityGrandTotal + assetGrandTotal) }}
+
+

+ Deposits and term loans are slotted by their own dates: a deposit once on its maturity date, a loan once + for each unpaid instalment on its due date. Anything already due goes in the first bucket (a loan + instalment follows the Overdue rule). CC / OD and NPA loans are divided by the bucket-wise % set for + them in the Configuration table. Deposit amounts are balances, loan amounts are principal; interest is a + separate line of the statement. +

+ } +
+ } + + @if (reportShown && selectBy === 'accounts') { +
+

+ {{ picked.length }} {{ picked.length === 1 ? 'account' : 'accounts' }} + as on {{ asOnIso }} · Amounts in {{ unitWord }} +

+ @if (reportLoading) { +

Loading…

+ } + @if (reportError) { +

{{ reportError }}

+ } + @if (report; as rep) { + + + + + + + + + + + + + @for (r of reportBuckets; track r.label) { + + + + + + + + + } + + + + + + + + + +
BucketDaysDatesAmount%Entries
{{ r.label }}{{ r.days }}{{ r.dates }}{{ amountText(r.amount) }}{{ r.amount ? (r.pct | number: '1.0-1') + '%' : '' }}{{ r.count || '' }}
Total{{ amountText(rep.total) }}{{ rep.total ? '100%' : '' }}{{ rep.count }}
+

+ A deposit counts once, on its maturity date; a loan counts once for each unpaid instalment, on its due + date. Anything already due goes in the first bucket (a loan instalment follows the Overdue rule). + Deposit amounts are balances, loan amounts are the principal of each instalment; interest is a separate + line of the statement. +

+ +

Each entry

+
+ + + + + + + + + + + + + + + @for (r of rep.rows; track $index) { + + + + + + + + + + + } @empty { + + + + } + +
AccountCustomerProductDue / maturity dateDaysBucketStatusAmount
+ {{ r.kind }} {{ r.accountNo }} + @if (r.installment) { + instalment {{ r.installment }} + } + {{ r.customer }} + {{ r.product }} + @if (r.note) { +
{{ r.note }}
+ } +
{{ r.date ? (r.date | date: 'dd-MMM-yyyy') : '—' }}{{ r.days === null ? '' : r.days }}{{ bucketName(r.bucket) }}{{ r.status }}{{ amountText(r.amount) }}
Nothing to show for these accounts on this date.
+
+ } +
+ } +
+ } @else { +
+
+

Bucket ranges

+ +
+

+ @if (rangeEdit) { + Change the Start / End of a bucket, or add and remove buckets. When you change the End of a bucket, the + Start of the next one and its heading change with it. + } @else { + Start and End of each bucket. Click Edit to change, add or remove buckets. + } +

+ + + + + + + + + @if (rangeEdit) { + + } + + + + @for (b of cfg.buckets; track $index) { + + + + + + + @if (rangeEdit) { + + } + + } + +
#StartEndHeadingDays / Dates
{{ $index + 1 }} + @if (fromOf($index); as f) { + @if (rangeEdit) { + + + + + } @else { + {{ f.n }} {{ unitLetter(f.unit) }} + } + } + + @if (b.upTo; as t) { + @if (rangeEdit) { + + + + + } @else { + {{ t.n }} {{ unitLetter(t.unit) }} + } + } @else { + end + } + + {{ b.label }} + @if (rangeProblem($index); as problem) { +
{{ problem }}
+ } +
+ @if (windows[$index]; as w) { + {{ dayRange(w) }} · {{ dateRange(w) }} + } + + @if (cfg.buckets.length > 2) { + + } +
+ + @if (rangeEdit) { + +
+ + Add a bucket ending at: + + + + + + + It is placed in the right position automatically and splits the bucket it falls inside into two. + +
+ } + +
+ + @if (dayCheck; as c) { +
+ @if (c.due) { + Already due ({{ -c.days }} days ago) → {{ c.bucket }} + } @else { + {{ c.days }} days left → {{ c.bucket }} + } +
+ } @else { +
Pick a maturity date and its bucket will show here.
+ } +
+
+ + + @for (sec of sections; track sec.side) { +
+ + + + + + + @if (editMode) { + + } + + + @for (b of cfg.buckets; track $index) { + + } + + + + @for (item of itemsOf(sec.side); track item.id) { + @if (item.header) { + + + + + @if (editMode) { + + } + + } @else { + + + + + + @if (item.rule === 'maturity') { + @if (autoAmounts(item); as amounts) { + + @for (v of amounts; track $index) { + + } + + } @else { + + + } + } @else if (item.rule === 'fixed') { + + @for (b of cfg.buckets; track $index) { + + } + } @else { + @for (b of cfg.buckets; track $index) { + + } + + } + + @if (editMode) { + + } + + } + } + +
{{ sec.title }}RESIDUAL MATURITYTotalLine
{{ b.label }}
+ @if (editMode) { + + } @else { + {{ item.ref }} + } + + @if (editMode) { + + } @else { + {{ item.name }} + } + + +
+ @if (editMode) { + + } @else { + {{ item.ref }} + } + + @if (editMode) { + + } @else { + {{ item.name }} + } + + + @if (item.rule === 'maturity' || item.rule === 'behavioural') { + + + } + {{ amountText(v) }} + {{ amountText(autoTotal(amounts)) }} + + @if (!item.source) { + This line has to be filled by hand: it has no account source yet + } @else if (loading) { + Data aa raha hai… + } @else { + No data came from the accounts + } + — + {{ share(item, $index) !== null ? '100%' : '' }} + + + + {{ rowState(item) === 'todo' ? 'fill in' : rowTotal(item) + '%' }} + + + +
+ @if (editMode) { + + } +
+ } + + @if (editMode) { +
+
Bucket names and day span
+ @for (b of cfg.buckets; track $index) { +
+ {{ $index + 1 }} + + +
+ @if (b.upTo) { + + + } @else { + No end + } + +
+
+ } + + @if (cfg.overdue; as o) { +
Overdue (due but not received): which bucket
+
+ + +
+ } +
+ } + + + @if (breakdown; as b) { +
+

Shown separately (not slotted by date)

+
    + @for (n of b.notSlotted; track n.key) { +
  • + {{ n.label }} — {{ n.accounts }} loan, outstanding + {{ amountText(n.outstanding) }} +
    {{ n.why }}
    +
  • + } +
+
    + @for (note of b.notes; track note) { +
  • {{ note }}
  • + } +
+
+ } + + +
+

What the report will show

+
+
Statement of Structural Liquidity as on {{ asOnIso }}
+
( Amounts in {{ unitWord }} )
+
+
    +
  • + Two tables, OUTFLOWS and INFLOWS, with the lines above. Each line’s amount + sits in its bucket, with a Total in the last column. +
  • +
  • A. TOTAL OUTFLOWS — the sum of all outflow lines, bucket by bucket.
  • +
  • B. TOTAL INFLOWS — the sum of all inflow lines, bucket by bucket.
  • +
  • C. MISMATCH (B − A) — inflow minus outflow, in each bucket.
  • +
  • + D. CUMULATIVE MISMATCH — running total of C, from the first bucket up to that bucket. +
  • +
  • E. C as % to A — mismatch divided by outflow, times 100.
  • + @for (l of cfg.limits; track $index) { +
  • + {{ cfg.buckets[l.bucket]?.label }}: negative mismatch must not exceed {{ l.pct }}%. The report shows it in red when it does. +
  • + } +
+

+ Where the amount comes from: By due date lines take their data from FD, RD and Loan accounts. + Fixed, Split and By study lines take the ledger (GL) + balance and apply the % above. Lines with no register have to be filled in by hand. +

+
+ } +
+
diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.scss b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.scss new file mode 100644 index 000000000..053fe58ca --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.scss @@ -0,0 +1,1341 @@ +/** + * 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 { + --g50: #f8fafc; + --g100: #f1f5f9; + --g200: #e2e8f0; + --g300: #cbd5e1; + --g500: #64748b; + --g700: #334155; + --g900: #0f172a; + --indigo: #4f46e5; + --indigo-l: #e0e7ff; + --liab: #b91c1c; + --liab-l: #fee2e2; + --asset: #15803d; + --asset-l: #dcfce7; + --warn: #b45309; + --warn-l: #fef3c7; + + /* The filed statement's header yellow */ + --sheet-head: #fff200; + --sheet-line: #1e293b; + + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: var(--g900); +} + +.bkd { + background: #fff; + border-radius: 14px; + display: flex; + flex-direction: column; + overflow: hidden; + max-height: 90vh; +} + +/* ── Header ── */ +.bkd-head { + padding: 16px 20px; + border-bottom: 1px solid var(--g200); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + background: linear-gradient(180deg, var(--g50), #fff); +} + +.bkd-head-left { + display: flex; + align-items: flex-start; + gap: 13px; +} + +.bkd-head-ic { + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + flex: 0 0 auto; + border-radius: 10px; + background: var(--indigo-l); + color: var(--indigo); + font-size: 17px; +} + +.bkd-title { + margin: 0; + font-size: 17px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.bkd-sub { + margin: 3px 0 0; + font-size: 12px; + color: var(--g500); +} + +.bkd-head-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.bkd-close { + border: 0; + background: var(--g100); + width: 30px; + height: 30px; + border-radius: 8px; + cursor: pointer; + color: var(--g700); +} + +.bkd-body { + padding: 18px 20px 22px; + overflow: auto; + width: 100%; + box-sizing: border-box; +} + +/* ── Buttons ── */ +.bkd-btn { + border: 1px solid var(--g300); + background: #fff; + color: var(--g700); + border-radius: 8px; + padding: 7px 12px; + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + + &:hover { + background: var(--g50); + } +} + +.bkd-btn-on { + background: var(--indigo); + border-color: var(--indigo); + color: #fff; + + &:hover { + background: var(--indigo); + } +} + +.bkd-btn-sm { + padding: 4px 9px; + font-size: 12px; +} + +.bkd-x { + border: 0; + background: var(--g100); + width: 24px; + height: 24px; + border-radius: 6px; + cursor: pointer; + color: var(--g700); +} + +.bkd-note { + margin: 0 0 14px; + padding: 9px 12px; + background: var(--g50); + border: 1px solid var(--g200); + border-radius: 8px; + font-size: 12.5px; + line-height: 1.5; + color: var(--g700); +} + +.bkd-warn { + color: var(--warn); + font-size: 12px; + margin: 8px 0 0; +} + +.bkd-banner { + padding: 8px 12px; + background: var(--warn-l); + border-radius: 8px; + margin: 0 0 14px; +} + +.bkd-none { + color: var(--g500); +} + +.bkd-empty { + font-size: 12.5px; + color: var(--g500); +} + +.bkd-dim { + opacity: 0.45; +} + +/* ── The configuration table, like the filed statement ── */ +.bkd-sheet-wrap { + margin-bottom: 20px; + overflow-x: auto; +} + +.bkd-sheet { + width: 100%; + min-width: 1180px; + border-collapse: collapse; + font-size: 12.5px; + + th, + td { + border: 1px solid var(--sheet-line); + padding: 0; + } + + thead th { + background: var(--sheet-head); + font-weight: 700; + text-align: center; + padding: 6px 4px; + line-height: 1.25; + } + + .s-title { + text-align: left; + vertical-align: bottom; + padding: 6px 8px; + font-size: 13px; + } + + .s-span { + font-size: 13px; + } + + .s-bucket { + width: 88px; + font-size: 11.5px; + } + + .s-total-h { + width: 62px; + } + + .s-tools-h { + width: 70px; + } +} + +.bkd-sheet tbody td { + padding: 3px 6px; + vertical-align: middle; +} + +.s-ref { + width: 40px; + text-align: right; + font-weight: 700; + color: var(--g700); + + input { + width: 36px; + padding: 2px 3px; + text-align: right; + } +} + +.s-name { + min-width: 270px; + font-weight: 600; + + input { + width: 100%; + } +} + +.s-sub { + padding-left: 18px !important; + font-weight: 500; +} + +.s-rule { + width: 104px; + + select { + width: 100%; + padding: 3px 4px; + font-size: 11.5px; + } +} + +.s-head td { + background: var(--g100); + font-weight: 700; +} + +.s-head-fill { + background: var(--g100); +} + +.s-cell { + text-align: center; + + input { + width: 100%; + min-width: 0; + border: 0; + background: transparent; + text-align: center; + padding: 4px 2px; + font: inherit; + font-size: 12.5px; + appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + margin: 0; + } + + &:focus { + outline: 2px solid var(--indigo); + background: #fff; + } + } +} + +.s-cell-on { + background: var(--asset-l); + + input { + font-weight: 700; + color: var(--asset); + } +} + +/* Figures the accounts put in a due-date line on their own */ +.s-auto { + text-align: right; + padding-right: 8px !important; + font-variant-numeric: tabular-nums; + font-weight: 600; + background: var(--asset-l); + color: var(--asset); + white-space: nowrap; +} + +.s-auto-zero { + background: transparent; + color: var(--g300); + font-weight: 400; +} + +.s-auto-total { + text-align: right; + background: var(--g50); +} + +.s-src { + margin-top: 3px; + width: 100%; + font-size: 11px !important; +} + +.bkd-toolbar { + display: flex; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 14px; + + label { + display: grid; + gap: 3px; + width: 180px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); + } +} + +.bkd-toolbar-state { + font-size: 12.5px; + color: var(--g500); + padding-bottom: 6px; +} + +.bkd-ok { + color: var(--asset); +} + +.bkd-apart-box { + margin: 0 0 12px; + padding: 10px 14px; + border: 1px solid var(--g200); + border-radius: 10px; + background: var(--g50); +} + +/* A fixed line moves by clicking the bucket it should go to */ +.s-pick { + cursor: pointer; + font-weight: 700; + color: var(--asset); + user-select: none; + + &:hover:not(.s-cell-on) { + background: var(--indigo-l); + } + + &:focus-visible { + outline: 2px solid var(--indigo); + outline-offset: -2px; + } +} + +/* A line carrying percentages: the boxes read as boxes, not as a coloured display */ +.bkd-sheet .s-cell-input input { + border: 1px solid var(--g300); + background: #fff; + border-radius: 4px; + margin: 1px 0; + + &:hover { + border-color: var(--indigo); + } + + &::placeholder { + color: var(--g300); + } +} + +.s-due { + text-align: center; + background: repeating-linear-gradient(45deg, var(--g50), var(--g50) 5px, var(--g100) 5px, var(--g100) 10px); + color: var(--g700); + font-size: 12px; +} + +.s-tot { + text-align: center; + font-weight: 700; + white-space: nowrap; +} + +.s-tot-ok { + color: var(--asset); +} + +.s-tot-bad { + background: var(--liab-l); + color: var(--liab); +} + +.s-tot-todo { + background: var(--warn-l); + color: var(--warn); +} + +.s-off td { + opacity: 0.45; +} + +.s-tools { + text-align: center; + white-space: nowrap; +} + +.s-incl { + display: inline-flex; + align-items: center; + margin-right: 4px; + cursor: pointer; +} + +.s-add { + margin-top: 8px; +} + +/* ── Report preview ── */ +.bkd-report { + margin-top: 8px; +} + +.bkd-h3 { + margin: 0 0 8px; + font-size: 15px; + font-weight: 700; +} + +.bkd-h4 { + margin: 18px 0 6px; + font-size: 13px; + font-weight: 700; +} + +.bkd-report-card { + border: 1px solid var(--sheet-line); + padding: 8px 12px; + text-align: center; + max-width: 560px; + + .r-title { + font-weight: 700; + font-size: 13.5px; + } + + .r-sub { + font-size: 12px; + color: var(--g700); + } +} + +.bkd-report-list { + margin: 12px 0 0; + padding-left: 18px; + font-size: 12.5px; + line-height: 1.7; + color: var(--g700); +} + +.bkd-limit { + color: var(--liab); +} + +/* ── Live data, collapsed until wanted ── */ +.bkd-live { + margin-top: 22px; + border: 1px solid var(--g200); + border-radius: 10px; + padding: 10px 14px 14px; + + summary { + cursor: pointer; + font-size: 13px; + font-weight: 700; + color: var(--g700); + } +} + +.bkd-tbl { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + font-size: 11.5px; + font-weight: 700; + color: var(--g500); + padding: 6px 8px; + border-bottom: 1px solid var(--g200); + } + + td { + padding: 8px; + border-bottom: 1px solid var(--g100); + vertical-align: middle; + } +} + +.bkd-num { + text-align: right !important; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.bkd-b-name { + font-weight: 600; +} + +.bkd-b-hint { + font-size: 11px; + color: var(--g500); +} + +.bkd-days-intro, +.bkd-days-note { + margin: 0 0 10px; + font-size: 12.5px; + color: var(--g700); + line-height: 1.5; +} + +.bkd-days-note { + margin: 8px 0 0; + color: var(--g500); +} + +.bkd-asof, +.bkd-calc label { + display: grid; + gap: 3px; + width: 190px; + margin: 12px 0 10px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); +} + +.bkd-calc { + display: flex; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; + margin-top: 16px; + padding: 12px; + border: 1px solid var(--g200); + border-radius: 10px; + background: var(--g50); + + label { + margin: 0; + } +} + +.bkd-calc-out { + font-size: 13.5px; + padding-bottom: 6px; +} + +.bkd-mat-wrap { + margin-top: 12px; + overflow-x: auto; + border: 1px solid var(--g200); + border-radius: 10px; +} + +.bkd-mat { + min-width: 920px; + font-size: 12.5px; + + th small { + display: block; + font-weight: 500; + color: var(--g500); + } + + td.bkd-num, + th.bkd-num { + width: auto; + white-space: nowrap; + } +} + +.bkd-mat-item { + min-width: 190px; + text-align: left; +} + +.bkd-mat-sec td { + background: var(--g100); + font-weight: 700; + font-size: 12px; +} + +.bkd-mat-liab td { + color: var(--liab); +} + +.bkd-mat-asset td { + color: var(--asset); +} + +.bkd-mat-total { + font-weight: 700; + background: var(--g50); +} + +.bkd-apart, +.bkd-notes { + margin: 0; + padding-left: 18px; + font-size: 12.5px; + line-height: 1.6; + color: var(--g700); +} + +.bkd-notes { + margin-top: 12px; + color: var(--g500); +} + +/* ── Editing buckets ── */ +.bkd-bucket-edit { + margin: 0 0 20px; + padding: 12px; + background: var(--g50); + border: 1px solid var(--g200); + border-radius: 8px; + display: grid; + gap: 8px; +} + +.bkd-bucket-edit-row { + display: grid; + grid-template-columns: 24px 1.4fr 1.4fr 230px; + gap: 8px; + align-items: center; +} + +.bkd-step-n { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--indigo-l); + color: var(--indigo); + font-size: 11.5px; + font-weight: 700; +} + +.bkd-editor-sub { + font-size: 11.5px; + font-weight: 700; + color: var(--g700); +} + +.bkd-span { + display: flex; + align-items: center; + gap: 6px; + + [type='number'] { + width: 64px; + } + + select { + width: 62px; + } + + .bkd-check { + white-space: nowrap; + } +} + +.bkd-check { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; +} + +.bkd-overdue-title { + margin-top: 8px; +} + +.bkd-overdue { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + + label { + display: grid; + gap: 3px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); + } +} + +/* :where() keeps these defaults at zero specificity, so the table's own cell inputs always win. */ +:where([type='text'], [type='number'], [type='date'], select, textarea) { + font: inherit; + font-size: 12.5px; + padding: 6px 8px; + border: 1px solid var(--g300); + border-radius: 6px; + background: #fff; + color: var(--g900); + min-width: 0; + box-sizing: border-box; +} + +:where([type='text'], select) { + width: 100%; +} + +:where([type='checkbox']) { + width: auto; +} + +@media (width <= 700px) { + .bkd-bucket-edit-row, + .bkd-overdue { + grid-template-columns: 1fr; + } +} + +/* ── Tabs ── */ +.bkd-tabs { + display: flex; + gap: 4px; + padding: 0 20px; + border-bottom: 1px solid var(--g200); + background: #fff; +} + +.bkd-tab { + border: 0; + background: none; + padding: 10px 14px; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--g500); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + + &:hover { + color: var(--g900); + } + + &.on { + color: var(--indigo); + border-bottom-color: var(--indigo); + } +} + +/* ── Day breakdown of one product ── */ +.bkd-focus { + margin-bottom: 12px; + + label { + display: grid; + gap: 3px; + width: 260px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); + } +} + +.bkd-focus-tbl { + max-width: 980px; + + tfoot td { + font-weight: 700; + border-top: 1px solid var(--g300); + border-bottom: 0; + } + + td.bkd-num { + font-weight: 600; + } +} + +/* ── Bucket ranges (From / To) ── */ +.bkd-h4-first { + margin-top: 0; +} + +.bkd-range { + max-width: 980px; +} + +.bkd-range-n { + width: 28px; + color: var(--g500); + font-weight: 700; +} + +.bkd-span-edit { + display: inline-flex; + gap: 4px; + align-items: center; + + [type='number'] { + width: 60px; + } + + select { + width: 62px; + } +} + +/* ── Accounts behind the chosen line ── */ +.bkd-acc-head { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + margin: 14px 0 8px; +} + +.bkd-acc-sum { + font-size: 12.5px; + color: var(--g700); +} + +.bkd-bad { + color: var(--liab); + font-weight: 700; +} + +.bkd-acc-cell { + white-space: nowrap; +} + +.bkd-link { + border: 0; + background: none; + color: var(--indigo); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 0 0 0 6px; +} + +.bkd-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0; +} + +.bkd-chip { + border: 1px solid var(--g300); + background: #fff; + border-radius: 999px; + padding: 3px 10px; + font: inherit; + font-size: 12px; + cursor: pointer; + + &.on { + background: var(--indigo-l); + border-color: var(--indigo); + color: var(--indigo); + font-weight: 700; + } +} + +.bkd-acc-wrap { + max-height: 360px; + overflow: auto; + border: 1px solid var(--g200); + border-radius: 10px; +} + +.bkd-acc { + font-size: 12.5px; + + thead th { + position: sticky; + top: 0; + background: var(--g50); + } +} + +/* ── Adding a bucket ── */ +.bkd-addbucket { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 10px 0 4px; + padding: 8px 10px; + border: 1px dashed var(--g300); + border-radius: 8px; +} + +.bkd-addbucket-title { + font-size: 12.5px; + font-weight: 600; + color: var(--g700); +} + +/* ── Bucket ranges: header with its own Edit button, read-only values ── */ +.bkd-range-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; + + .bkd-h4 { + margin: 0; + } +} + +.bkd-range-val { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* ── Choosing what to break down ── */ +.bkd-pick-group { + margin-bottom: 12px; +} + +.bkd-pick-title { + font-size: 12px; + font-weight: 700; + margin-bottom: 6px; +} + +.bkd-pick-liab { + color: var(--liab); +} + +.bkd-pick-asset { + color: var(--asset); +} + +.bkd-pick-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.bkd-pick-opt { + max-width: 340px; + text-align: left; + border: 1px solid var(--g300); + background: #fff; + border-radius: 8px; + padding: 7px 11px; + font: inherit; + font-size: 12.5px; + line-height: 1.35; + color: var(--g900); + cursor: pointer; + + &:hover { + border-color: var(--indigo); + } + + &.on { + border-color: var(--indigo); + background: var(--indigo-l); + color: var(--indigo); + font-weight: 600; + } +} + +.bkd-pick-ref { + font-weight: 700; + margin-right: 4px; + color: var(--g500); +} + +.bkd-pick-opt.on .bkd-pick-ref { + color: var(--indigo); +} + +.bkd-pick-nodata { + margin-left: 6px; + font-size: 10.5px; + font-weight: 600; + color: var(--warn); + background: var(--warn-l); + padding: 1px 6px; + border-radius: 999px; +} + +.bkd-submit-row { + display: flex; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; + + label { + display: grid; + gap: 3px; + width: 180px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); + } +} + +.bkd-submit { + padding: 8px 26px; + font-size: 13px; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.bkd-report-out { + margin-top: 22px; + padding-top: 14px; + border-top: 2px solid var(--g200); +} + +.bkd-report-title { + margin-top: 0; + font-size: 15px; + + small { + display: block; + margin-top: 2px; + font-size: 12px; + font-weight: 500; + color: var(--g500); + } +} + +.bkd-setup { + margin-bottom: 20px; + padding: 12px 14px 14px; + border: 1px solid var(--g200); + border-radius: 10px; + background: var(--g50); +} + +/* ── Editing the picker ── */ +.bkd-pick-wrap { + display: inline-flex; + align-items: stretch; +} + +.bkd-pick-del { + border: 1px solid var(--g300); + border-left: 0; + background: var(--g100); + border-radius: 0 8px 8px 0; + padding: 0 8px; + color: var(--liab); + cursor: pointer; + + &:hover { + background: var(--liab-l); + } +} + +.bkd-pick-wrap:has(.bkd-pick-del) .bkd-pick-opt { + border-radius: 8px 0 0 8px; +} + +.bkd-pick-edit { + margin: 4px 0 12px; + padding: 10px 12px; + border: 1px dashed var(--g300); + border-radius: 8px; + background: var(--g50); + display: grid; + gap: 10px; +} + +.bkd-pick-acc { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + font-size: 12.5px; + + select { + min-width: 260px; + } +} + +.bkd-pick-add label { + display: grid; + gap: 3px; + max-width: 460px; + font-size: 11.5px; + font-weight: 600; + color: var(--g700); +} + +.bkd-pick-checks { + display: flex; + flex-wrap: wrap; + gap: 8px 22px; +} + +/* ── Searching and picking accounts ── */ +.bkd-search { + display: flex; + gap: 8px; + max-width: 720px; + + [type='text'] { + flex: 1; + padding: 9px 11px; + font-size: 13px; + } + + select { + width: 150px; + } +} + +.bkd-hits { + display: grid; + gap: 6px; + max-width: 720px; + margin-top: 10px; + max-height: 300px; + overflow: auto; +} + +.bkd-hit { + display: grid; + grid-template-columns: 46px 1fr auto 74px; + align-items: center; + gap: 10px; + text-align: left; + border: 1px solid var(--g300); + background: #fff; + border-radius: 8px; + padding: 8px 10px; + font: inherit; + font-size: 12.5px; + cursor: pointer; + + &:hover { + border-color: var(--indigo); + } + + &.picked { + background: var(--indigo-l); + border-color: var(--indigo); + } +} + +.bkd-hit-kind { + font-size: 10.5px; + font-weight: 700; + text-align: center; + padding: 2px 0; + border-radius: 5px; + background: var(--g100); + color: var(--g700); +} + +.bkd-hit-main small { + display: block; + color: var(--g500); + font-size: 11.5px; +} + +.bkd-hit-amt { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.bkd-hit-state { + font-size: 11.5px; + font-weight: 700; + color: var(--indigo); + text-align: right; +} + +.bkd-picked { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 8px; + margin-top: 12px; + font-size: 12.5px; +} + +.bkd-pill-sel { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 4px 3px 10px; + border-radius: 999px; + background: var(--indigo-l); + color: var(--indigo); + font-weight: 600; + + button { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0 6px; + } +} + +/* ── Switch between kinds of account and individual accounts ── */ +.bkd-modes { + display: inline-flex; + border: 1px solid var(--g300); + border-radius: 8px; + overflow: hidden; + margin-bottom: 12px; +} + +.bkd-mode { + border: 0; + background: #fff; + padding: 7px 16px; + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: var(--g700); + cursor: pointer; + + & + & { + border-left: 1px solid var(--g300); + } + + &.on { + background: var(--indigo); + color: #fff; + } +} + +.bkd-pick-box { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-right: 8px; + border: 1px solid var(--g300); + border-radius: 4px; + background: #fff; + font-size: 11px; + line-height: 1; + vertical-align: -3px; +} + +.bkd-pick-opt.on .bkd-pick-box { + border-color: var(--indigo); + background: var(--indigo); + color: #fff; +} + +.bkd-pick-actions { + display: flex; + gap: 6px; + margin: -4px 0 8px; +} + +.bkd-matrix-wrap { + max-height: none; +} + +.bkd-matrix { + font-size: 12.5px; + min-width: 640px; +} diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.ts b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.ts new file mode 100644 index 000000000..d819d7d0c --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component.ts @@ -0,0 +1,962 @@ +/** + * 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, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Subject, catchError, debounceTime, of, switchMap } from 'rxjs'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { businessTodayIso } from 'app/core/utils/business-today'; +import { + AlmBucket, + AlmConfig, + AlmItem, + AlmRule, + AlmSide, + AlmPlacement, + AlmSource, + AlmSpan, + AlmUnit, + RULE_OPTIONS, + SHARE_SOURCES, + SOURCE_OPTIONS, + defaultConfig +} from './alm-bucket-breakdown.config'; +import { + BucketWindow, + addSpan, + bucketIndexFor, + bucketLabel, + bucketWindows, + dayRangeText, + daysBetween, + formatDate, + fromSpanOf, + parseIsoDate, + setFromSpan +} from './alm-bucket.util'; +import { AccountHit, AccountRow, AlmMaturityService, MaturityBreakdown, SelectedReport } from './alm-maturity.service'; + +type AmountUnit = 'rupee' | 'lakh' | 'crore'; + +/** How a line's shares stand: whether the bank still has something to fill in. */ +type RowState = 'spread' | 'ok' | 'todo' | 'bad'; + +/** Which of the server's account groups each source adds together. */ +const SOURCE_GROUPS: Record<'FD_RD' | 'FD' | 'RD' | 'LOAN', string[]> = { + FD_RD: [ + 'FD', + 'RD' + ], + FD: ['FD'], + RD: ['RD'], + LOAN: ['LOAN'] +}; + +/** The two tables of the statement, in the order it prints them. */ +const SECTIONS: { side: AlmSide; title: string }[] = [ + { side: 'liability', title: 'OUTFLOWS' }, + { side: 'asset', title: 'INFLOWS' } +]; + +/** + * Where the browser keeps the bank's edits. Same arrangement as the OSS GL mapping: there is no + * backend table for this yet, so the setting belongs to the browser it was made in. The suffix is + * the shape of what is stored; a different shape starts again from RBI's defaults. + */ +const STORAGE_KEY = 'alm_bucket_breakdown_config_v2'; + +/** + * ALM Time-Bucket Breakdown — the configuration of the Structural Liquidity Statement. + * + * One table laid out like the statement the bank files: the same numbered Outflow and Inflow lines + * down the side, the same residual-maturity buckets across the top. Each line says how its balance + * is slotted — wholly in one bucket, by the due date of each account, or by percentages the bank + * sets. The statement itself is computed from this configuration. + * + * Rules start from RBI's Urban Co-operative Bank defaults but none is fixed: every line, bucket and + * percentage can be edited, and "Reset" returns to RBI's. + */ +@Component({ + selector: 'mifosx-alm-bucket-breakdown-dialog', + standalone: true, + templateUrl: './alm-bucket-breakdown-dialog.component.html', + styleUrls: ['./alm-bucket-breakdown-dialog.component.scss'], + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatTooltipModule, + FaIconComponent + ] +}) +export class AlmBucketBreakdownDialogComponent { + private dialogRef = inject(MatDialogRef); + private service = inject(AlmMaturityService); + private destroyRef = inject(DestroyRef); + + readonly ruleOptions = RULE_OPTIONS; + readonly sourceOptions = SOURCE_OPTIONS; + readonly sections = SECTIONS; + + cfg: AlmConfig = this.load(); + /** Which tab is open: the day breakdown of one product, or the statement's configuration table. */ + view: 'days' | 'config' = 'days'; + /** Lines can be renamed, added, removed and left out of the report only while this is on. */ + editMode = false; + + /** The date the buckets are counted from — the business date, not the browser's clock. */ + asOnIso = businessTodayIso(); + /** Maturity typed into the day calculator. */ + maturityIso = ''; + + /** Amounts come back in rupees; the bank reads them in lakhs unless told otherwise. */ + readonly units: { value: AmountUnit; label: string; divisor: number }[] = [ + { value: 'rupee', label: '₹ (rupaye)', divisor: 1 }, + { value: 'lakh', label: 'Lakh', divisor: 100_000 }, + { value: 'crore', label: 'Crore', divisor: 10_000_000 } + ]; + unit: AmountUnit = 'lakh'; + + /** FD / RD / loan totals per bucket for the as-on date, from the server. */ + breakdown: MaturityBreakdown | null = null; + loading = false; + loadError = ''; + /** Only the newest request's answer may land; a slow earlier one must not overwrite it. */ + private loadSeq = 0; + + constructor() { + this.watchSearch(); + this.loadBreakdown(); + } + + // ── The configuration table ── + + /** The lines of one table, headings included, in statement order. */ + itemsOf(side: AlmSide): AlmItem[] { + return this.cfg.items.filter((i) => i.side === side); + } + + /** Columns across a table: reference, line, rule, every bucket, total, and the row tools while editing. */ + get columnCount(): number { + return 3 + this.cfg.buckets.length + 1 + (this.editMode ? 1 : 0); + } + + /** Share of a line's balance that goes to one bucket, or null when none does. */ + share(item: AlmItem, bucket: number): number | null { + const placed = item.placements.filter((p) => p.bucket === bucket); + if (!placed.length) { + return null; + } + const explicit = placed.filter((p) => p.pct !== null && p.pct !== undefined && `${p.pct}` !== ''); + if (explicit.length) { + return explicit.reduce((sum, p) => sum + Number(p.pct), 0); + } + // No share written down: a line with one bucket puts the whole balance there. + return item.placements.length === 1 ? 100 : null; + } + + /** Set the share going to one bucket; empty or zero takes the bucket out of the line. */ + setShare(item: AlmItem, bucket: number, raw: number | string | null): void { + const value = raw === null || raw === '' ? 0 : Math.max(0, Math.min(100, Number(raw))); + const rest = item.placements.filter((p) => p.bucket !== bucket); + if (value > 0 && !Number.isNaN(value)) { + const kept = item.placements.find((p) => p.bucket === bucket); + rest.push({ bucket, pct: value, label: kept?.label }); + } + item.placements = rest.sort((a, b) => a.bucket - b.bucket); + this.changed(); + } + + /** + * The amounts a due-date line gets from its accounts, in rupees; null while there is nothing to + * show (no source chosen, not loaded yet, or the server could not answer). + */ + autoAmounts(item: AlmItem): number[] | null { + return item.rule === 'maturity' ? this.itemAmounts(item) : null; + } + + /** + * What a line has in each bucket, in rupees. Deposits and term loans come bucket by bucket from + * their own dates; cash-credit and NPA loans come as one outstanding figure that the line's own + * percentages divide. Null when the line has no accounts behind it, or nothing has loaded yet. + */ + itemAmounts(item: AlmItem | null): number[] | null { + return item?.source ? this.amountsOf(item.source, item) : null; + } + + /** + * What one kind of account has in each bucket. For cash-credit and NPA loans the percentages come + * from the statement line that names that kind, and are zero until that line has them. + */ + private amountsOf(source: AlmSource, line: AlmItem | null): number[] | null { + if (!this.breakdown) { + return null; + } + if (SHARE_SOURCES.includes(source)) { + const outstanding = this.apartLine(source)?.outstanding ?? 0; + return this.cfg.buckets.map((_b, i) => (outstanding * (line ? (this.share(line, i) ?? 0) : 0)) / 100); + } + const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS]; + const groups = this.breakdown.groups.filter((g) => keys?.includes(g.key)); + if (!groups.length) { + return null; + } + return this.cfg.buckets.map((_b, i) => groups.reduce((sum, g) => sum + (g.buckets[i] ?? 0), 0)); + } + + /** The loans the server reports apart from the date buckets: CC/OD, or NPA. */ + private apartLine(source: AlmSource): { outstanding: number; accounts: number } | undefined { + const key = source === 'CCOD' ? 'REVOLVING' : 'NPA'; + return this.breakdown?.notSlotted.find((n) => n.key === key); + } + + /** Accounts behind a line, for the tooltip and the day view's total. */ + autoAccounts(item: AlmItem | null): number { + const source = item?.source; + if (!source || !this.breakdown) { + return 0; + } + if (SHARE_SOURCES.includes(source)) { + return this.apartLine(source)?.accounts ?? 0; + } + const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS] ?? []; + return this.breakdown.groups.filter((g) => keys.includes(g.key)).reduce((sum, g) => sum + g.accounts, 0); + } + + autoTotal(amounts: number[]): number { + return amounts.reduce((sum, v) => sum + v, 0); + } + + // ── Picking kinds of account ── + + /** Whether the day-wise breakdown is by kind of account, or for individual accounts found by search. */ + selectBy: 'types' | 'accounts' = 'types'; + + /** The kinds of account that can be picked, on the liability and the asset side. */ + readonly pickTypes: { value: AlmSource; label: string; side: AlmSide }[] = [ + { value: 'FD', label: 'Fixed Deposits (FD)', side: 'liability' }, + { value: 'RD', label: 'Recurring Deposits (RD)', side: 'liability' }, + { value: 'LOAN', label: 'Term Loans', side: 'asset' }, + { value: 'CCOD', label: 'CC / OD Loans', side: 'asset' }, + { value: 'NPA', label: 'NPA Loans', side: 'asset' } + ]; + + /** The kinds picked; any number of them, from either side. */ + pickedTypes: AlmSource[] = [ + 'FD', + 'RD' + ]; + + get pickGroups(): { side: AlmSide; title: string; items: { value: AlmSource; label: string; side: AlmSide }[] }[] { + return [ + { + side: 'liability', + title: 'Liabilities: deposits (customers)', + items: this.pickTypes.filter((t) => t.side === 'liability') + }, + { side: 'asset', title: 'Assets: loans (customers)', items: this.pickTypes.filter((t) => t.side === 'asset') } + ]; + } + + setSelectBy(mode: 'types' | 'accounts'): void { + this.selectBy = mode; + this.reportShown = false; + } + + isTypePicked(value: AlmSource): boolean { + return this.pickedTypes.includes(value); + } + + /** Tick or untick a kind. Any number can be ticked together; a report already open is put away. */ + toggleType(value: AlmSource): void { + this.pickedTypes = this.isTypePicked(value) ? this.pickedTypes.filter((v) => v !== value) : [ + ...this.pickedTypes, + value + ]; + this.reportShown = false; + } + + selectAllTypes(): void { + this.pickedTypes = this.pickTypes.map((t) => t.value); + this.reportShown = false; + } + + clearTypes(): void { + this.pickedTypes = []; + this.reportShown = false; + } + + /** One column per kind picked: what it has in every bucket, and a note where something is still to be filled in. */ + get typeColumns(): { + value: AlmSource; + label: string; + side: AlmSide; + amounts: number[]; + total: number; + notice: string; + }[] { + return this.pickTypes + .filter((t) => this.isTypePicked(t.value)) + .map((t) => { + const amounts = + this.amountsOf(t.value, this.cfg.items.find((i) => !i.header && i.source === t.value) ?? null) ?? + this.cfg.buckets.map(() => 0); + const total = this.autoTotal(amounts); + const outstanding = SHARE_SOURCES.includes(t.value) ? (this.apartLine(t.value)?.outstanding ?? 0) : 0; + const notice = + outstanding > 0 && total === 0 + ? `The bucket-wise % for this kind are not filled in yet, so nothing is divided. Fill them in the Configuration table (outstanding is ${this.amountText(outstanding)}).` + : ''; + return { value: t.value, label: t.label, side: t.side, amounts, total, notice }; + }); + } + + /** One row per bucket across the picked kinds, with the liability and asset sides added up separately. */ + get typeMatrix(): { label: string; days: string; dates: string; cells: number[]; liab: number; asset: number }[] { + const cols = this.typeColumns; + return this.windows.map((w, i) => { + const cells = cols.map((c) => c.amounts[i] ?? 0); + return { + label: this.cfg.buckets[i]?.label ?? w.label, + days: dayRangeText(w), + dates: this.dateRange(w), + cells, + liab: cols.reduce((sum, c, k) => sum + (c.side === 'liability' ? cells[k] : 0), 0), + asset: cols.reduce((sum, c, k) => sum + (c.side === 'asset' ? cells[k] : 0), 0) + }; + }); + } + + get liabilityGrandTotal(): number { + return this.typeColumns.reduce((sum, c) => sum + (c.side === 'liability' ? c.total : 0), 0); + } + + get assetGrandTotal(): number { + return this.typeColumns.reduce((sum, c) => sum + (c.side === 'asset' ? c.total : 0), 0); + } + + get typeHasLiability(): boolean { + return this.typeColumns.some((c) => c.side === 'liability'); + } + + get typeHasAsset(): boolean { + return this.typeColumns.some((c) => c.side === 'asset'); + } + + // ── Picking accounts one by one ── + + /** What is typed in the search box. */ + searchText = ''; + /** Which kind of account the search looks in: all, or one kind. */ + searchKind: '' | 'FD' | 'RD' | 'LOAN' = ''; + searchResults: AccountHit[] = []; + searching = false; + searchError = ''; + /** The accounts picked so far. */ + picked: AccountHit[] = []; + private searchTyped = new Subject(); + + /** Whether the report of the picked accounts is on screen: only after Submit, and until the picks or the date change. */ + reportShown = false; + report: SelectedReport | null = null; + reportLoading = false; + reportError = ''; + /** Only the newest request's answer may land; a slow earlier one must not overwrite it. */ + private reportSeq = 0; + + /** Set up the search so it waits for typing to pause, and a slow answer to an old search never replaces a newer one. */ + private watchSearch(): void { + this.searchTyped + .pipe( + debounceTime(300), + switchMap(() => + this.service.searchAccounts(this.searchText.trim(), this.searchKind).pipe( + catchError((err) => { + this.searchError = + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + 'Search failed. The backend needs a restart with the new endpoint.'; + return of({ results: [] as AccountHit[] }); + }) + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((found) => { + this.searching = false; + this.searchResults = found.results; + }); + } + + /** Typing or changing the kind: search again once typing pauses. */ + onSearchInput(): void { + this.searchError = ''; + if (this.searchText.trim().length < 2) { + this.searchResults = []; + this.searching = false; + return; + } + this.searching = true; + this.searchTyped.next(); + } + + /** Whether an account is already picked. */ + isPicked(hit: AccountHit): boolean { + return this.picked.some((p) => p.kind === hit.kind && p.id === hit.id); + } + + pick(hit: AccountHit): void { + if (!this.isPicked(hit)) { + this.picked = [ + ...this.picked, + hit + ]; + this.reportShown = false; + } + } + + unpick(hit: AccountHit): void { + this.picked = this.picked.filter((p) => !(p.kind === hit.kind && p.id === hit.id)); + this.reportShown = false; + } + + clearPicked(): void { + this.picked = []; + this.reportShown = false; + } + + /** Show the day-wise breakdown of the picked accounts for the chosen date. */ + submitReport(): void { + if (this.selectBy === 'types') { + if (this.pickedTypes.length) { + this.reportShown = true; + this.loadBreakdown(); + } + return; + } + if (!this.picked.length) { + return; + } + this.reportShown = true; + this.loadReport(); + } + + /** Whether Submit has anything to submit. */ + get canSubmit(): boolean { + return this.selectBy === 'types' ? this.pickedTypes.length > 0 : this.picked.length > 0; + } + + /** Fetch the report of the picked accounts; also called again when the bucket setting changes under an open report. */ + private loadReport(): void { + const seq = ++this.reportSeq; + this.reportError = ''; + if (!parseIsoDate(this.asOnIso)) { + this.report = null; + this.reportError = 'The as-on date is not valid.'; + return; + } + if (this.bucketSpecInvalid) { + this.report = null; + this.reportError = 'Only the last bucket may have no end; every other bucket needs an end.'; + return; + } + this.reportLoading = true; + this.service + .getSelected(this.picked.map((p) => `${p.kind}:${p.id}`).join(','), { + asOnDate: this.asOnIso, + buckets: this.edgeSpec, + overdueRecentBucket: this.cfg.overdue?.recent ?? 0, + overdueOldBucket: this.cfg.overdue?.old ?? 2 + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (result) => { + if (seq === this.reportSeq) { + this.reportLoading = false; + this.report = result; + } + }, + error: (err) => { + if (seq === this.reportSeq) { + this.reportLoading = false; + this.report = null; + this.reportError = + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + 'No data came back. The backend needs a restart with the new endpoint.'; + } + } + }); + } + + /** A new as-on date means a new report: put the old one away until Submit is pressed again. */ + onAsOnChange(): void { + this.reportShown = false; + this.loadBreakdown(); + } + + /** The report by bucket: what the picked accounts put in each one, with the days and dates of the bucket. */ + get reportBuckets(): { label: string; days: string; dates: string; amount: number; count: number; pct: number }[] { + const rows = this.report?.rows ?? []; + const total = rows.reduce((sum, r) => sum + r.amount, 0); + return this.windows.map((w, i) => { + const inBucket = rows.filter((r) => r.bucket === i); + const amount = inBucket.reduce((sum, r) => sum + r.amount, 0); + return { + label: this.cfg.buckets[i]?.label ?? w.label, + days: dayRangeText(w), + dates: this.dateRange(w), + amount, + count: inBucket.length, + pct: total ? (amount / total) * 100 : 0 + }; + }); + } + + bucketName(index: number | null): string { + return index === null ? '' : (this.cfg.buckets[index]?.label ?? ''); + } + + // ── The bucket ranges, as a From / To table ── + + /** Where a bucket starts, as the table writes it; null for the first, which always starts on day 1. */ + fromOf(index: number): AlmSpan | null { + return fromSpanOf(this.cfg.buckets, index); + } + + /** Change where a bucket starts. That is where the one before it ends, so that one moves too. */ + setFrom(index: number, change: { n?: number | string; unit?: AlmUnit }): void { + const current = this.fromOf(index); + if (!current) { + return; + } + const n = change.n !== undefined ? Number(change.n) : current.n; + this.applyRange(index - 1, () => + setFromSpan(this.cfg.buckets, index, { n: n > 0 ? n : 1, unit: change.unit ?? current.unit }) + ); + } + + /** Change where a bucket ends. */ + setTo(index: number, change: { n?: number | string; unit?: AlmUnit }): void { + const current = this.cfg.buckets[index]?.upTo; + if (!current) { + return; + } + const n = change.n !== undefined ? Number(change.n) : current.n; + this.applyRange(index, () => { + this.cfg.buckets[index].upTo = { n: n > 0 ? n : 1, unit: change.unit ?? current.unit }; + }); + } + + /** + * Apply a change to the end of one bucket, and keep the headings of the two buckets it touches in + * step — but only a heading still worded the way the table would word it; one the bank has typed + * over is left alone. + */ + private applyRange(endingIndex: number, apply: () => void): void { + const touched = [ + endingIndex, + endingIndex + 1 + ].filter((i) => i >= 0 && i < this.cfg.buckets.length); + const wasGenerated = touched.map((i) => this.cfg.buckets[i].label === bucketLabel(this.cfg.buckets, i)); + apply(); + touched.forEach((i, k) => { + if (wasGenerated[k]) { + this.cfg.buckets[i].label = bucketLabel(this.cfg.buckets, i); + } + }); + this.bucketsChanged(); + } + + /** The end typed into the "add a bucket" form. */ + newEnd: { n: number; unit: AlmUnit } = { n: 7, unit: 'day' }; + + /** Start, End, adding and removing buckets work only while this is on, so a stray click cannot change them. */ + rangeEdit = false; + + toggleRangeEdit(): void { + this.rangeEdit = !this.rangeEdit; + } + + /** 'd', 'm' or 'y' — a unit the way the range table writes it. */ + unitLetter(unit: AlmUnit): string { + return unit === 'day' ? 'd' : unit === 'month' ? 'm' : 'y'; + } + + /** + * Add a bucket ending where the form says. It lands in date order and takes the front part of the + * bucket it falls inside, so what was placed there stays with the later part; lines, the overdue + * rule and limits keep pointing at the buckets they pointed at. + */ + addBucket(): void { + const end: AlmSpan = { n: Math.max(1, Number(this.newEnd.n) || 1), unit: this.newEnd.unit }; + const buckets = this.cfg.buckets; + const reference = parseIsoDate(this.asOnIso) ?? new Date(); + const endTime = addSpan(reference, end).getTime(); + if (buckets.some((b) => b.upTo && addSpan(reference, b.upTo).getTime() === endTime)) { + window.alert('A bucket already ends there.'); + return; + } + let position = buckets.findIndex((b) => !b.upTo || addSpan(reference, b.upTo).getTime() > endTime); + if (position < 0) { + position = buckets.length; + } + const laterWasGenerated = position < buckets.length && buckets[position].label === bucketLabel(buckets, position); + this.shiftBucketRefs((i) => (i >= position ? i + 1 : i)); + // A bucket put in front becomes the first and starts on day 1; the old first now starts where it ends. + if (position === 0 && buckets[0]) { + buckets[0].startSpan = undefined; + } + buckets.splice(position, 0, { label: '', hint: '', upTo: end }); + buckets[position].label = bucketLabel(buckets, position); + if (laterWasGenerated && position + 1 < buckets.length) { + buckets[position + 1].label = bucketLabel(buckets, position + 1); + } + this.bucketsChanged(); + } + + /** + * Remove a bucket. Whatever was placed in it moves to the next one (to the one before, if it was + * the last, which then runs on without end), so no line loses its money. + */ + removeBucket(index: number): void { + const buckets = this.cfg.buckets; + if (buckets.length <= 2) { + return; + } + if (!window.confirm(`Remove the "${buckets[index].label}" bucket? Its money will move to the next bucket.`)) { + return; + } + const last = index === buckets.length - 1; + const target = last ? index - 1 : index + 1; + const targetWasGenerated = buckets[target].label === bucketLabel(buckets, target); + this.shiftBucketRefs((i) => { + const moved = i === index ? target : i; + return moved > index ? moved - 1 : moved; + }); + const removedStart = buckets[index].startSpan; + buckets.splice(index, 1); + // Removing the first bucket makes the next one the first, which keeps the statement's start. + if (index === 0 && removedStart) { + buckets[0].startSpan = removedStart; + } + if (last) { + buckets[index - 1].upTo = null; + } + const now = last ? index - 1 : index; + if (targetWasGenerated) { + buckets[now].label = bucketLabel(buckets, now); + } + this.mergeShares(); + this.bucketsChanged(); + } + + /** Re-point everything that names a bucket by its position: lines, the overdue rule, the limits. */ + private shiftBucketRefs(map: (index: number) => number): void { + this.cfg.items.forEach((item) => item.placements.forEach((p) => (p.bucket = map(p.bucket)))); + if (this.cfg.overdue) { + this.cfg.overdue.recent = map(this.cfg.overdue.recent); + this.cfg.overdue.old = map(this.cfg.overdue.old); + } + this.cfg.limits.forEach((l) => (l.bucket = map(l.bucket))); + } + + /** After two buckets became one, a line's two shares there become one share, and a limit is kept once. */ + private mergeShares(): void { + this.cfg.items.forEach((item) => { + const merged = new Map(); + for (const p of item.placements) { + const have = merged.get(p.bucket); + if (!have) { + merged.set(p.bucket, { ...p }); + } else if (have.pct != null || p.pct != null) { + have.pct = (Number(have.pct) || 0) + (Number(p.pct) || 0); + } + } + item.placements = [...merged.values()].sort((a, b) => a.bucket - b.bucket); + }); + const seen = new Set(); + this.cfg.limits = this.cfg.limits.filter((l) => !seen.has(l.bucket) && !!seen.add(l.bucket)); + } + + /** A bucket that ends before it starts, or no later than the one before. Empty when the range is fine. */ + rangeProblem(index: number): string { + const w = this.windows[index]; + if (!w || !w.to) { + return ''; + } + return w.to < w.from ? 'End is before Start: keep it later than the end of the previous bucket.' : ''; + } + + /** Move a whole line to one bucket: its balance all goes there and nowhere else. */ + setFixed(item: AlmItem, bucket: number): void { + const label = item.placements.find((p) => p.bucket === bucket)?.label; + item.placements = [{ bucket, pct: 100, label }]; + this.changed(); + } + + /** What a line's shares add up to, or null for a line that follows due dates. */ + rowTotal(item: AlmItem): number | null { + if (item.rule === 'maturity') { + return null; + } + return this.cfg.buckets.reduce((sum, _b, i) => sum + (this.share(item, i) ?? 0), 0); + } + + rowState(item: AlmItem): RowState { + const total = this.rowTotal(item); + if (total === null) { + return 'spread'; + } + if (total === 0) { + return 'todo'; + } + return Math.abs(total - 100) < 0.005 ? 'ok' : 'bad'; + } + + /** Shares that do not make up the whole balance, over every line that is in the report. */ + get problemCount(): number { + return this.cfg.items.filter( + (i) => !i.header && i.included !== false && [ + 'bad', + 'todo' + ].includes(this.rowState(i)) + ).length; + } + + /** Changing a line's rule: due-date lines follow each account's date, the rest carry shares. */ + ruleChanged(item: AlmItem, rule: AlmRule): void { + item.rule = rule; + item.spreads = rule === 'maturity'; + if (rule === 'fixed') { + // A fixed line sits in one bucket: keep the one that held the most, or start in the first. + const biggest = [...item.placements].sort((a, b) => (Number(b.pct) || 0) - (Number(a.pct) || 0))[0]; + item.placements = [{ bucket: biggest?.bucket ?? 0, pct: 100, label: biggest?.label }]; + } + this.changed(); + } + + // ── Editing the lines themselves ── + + toggleEdit(): void { + this.editMode = !this.editMode; + } + + addLine(side: AlmSide): void { + const items = this.itemsOf(side); + const last = items[items.length - 1]; + this.cfg.items.splice(this.cfg.items.indexOf(last) + 1, 0, { + id: `u${Date.now()}`, + side, + ref: '', + level: 0, + name: side === 'liability' ? 'Nayi liability' : 'Nayi asset', + rule: 'fixed', + reason: '', + spreads: false, + placements: [{ bucket: 0, pct: 100 }] + }); + this.changed(); + } + + removeLine(item: AlmItem): void { + if (!window.confirm(`"${item.name}" hata dein?`)) { + return; + } + this.cfg.items = this.cfg.items.filter((i) => i.id !== item.id); + this.changed(); + } + + /** Open-ended means "and beyond" — only the last bucket should be. */ + setOpenEnded(bucket: AlmBucket, open: boolean): void { + bucket.upTo = open ? null : { n: 5, unit: 'year' }; + this.bucketsChanged(); + } + + /** Called after any edit — the change is kept right away, there is no separate save step. */ + changed(): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.cfg)); + } catch { + /* private window or blocked storage — the edit still holds for this session */ + } + } + + reset(): void { + if (!window.confirm('All settings will go back to the RBI defaults and your changes will be lost. Continue?')) { + return; + } + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* nothing stored to remove */ + } + this.cfg = defaultConfig(); + this.loadBreakdown(); + } + + close(): void { + this.dialogRef.close(); + } + + // ── Days from the as-on date ── + + /** Every bucket as dates, for the chosen as-on date. */ + get windows(): BucketWindow[] { + const asOn = parseIsoDate(this.asOnIso); + return asOn ? bucketWindows(asOn, this.cfg.buckets) : []; + } + + /** Day-by-day text for a window: 'Din 1 se 14'. */ + dayRange(w: BucketWindow): string { + return dayRangeText(w); + } + + dateRange(w: BucketWindow): string { + return w.to ? `${formatDate(w.from)} to ${formatDate(w.to)}` : `${formatDate(w.from)} onwards`; + } + + /** What the day calculator says for the maturity typed in; null until both dates are valid. */ + get dayCheck(): { days: number; bucket: string; due: boolean } | null { + const asOn = parseIsoDate(this.asOnIso); + const maturity = parseIsoDate(this.maturityIso); + if (!asOn || !maturity) { + return null; + } + const days = daysBetween(asOn, maturity); + return { + days, + due: days <= 0, + bucket: this.cfg.buckets[bucketIndexFor(asOn, maturity, this.cfg.buckets)]?.label ?? '' + }; + } + + // ── Live FD / RD / loan totals for the as-on date ── + + /** The bank's bucket edges as the server reads them: '14d,28d,3m,6m,1y,3y,5y'. */ + private get edgeSpec(): string { + const letter = { day: 'd', month: 'm', year: 'y' }; + return this.cfg.buckets + .filter((b) => b.upTo) + .map((b) => `${b.upTo?.n}${letter[b.upTo?.unit ?? 'day']}`) + .join(','); + } + + /** Only the last bucket may be open-ended, and every other one needs an end. */ + private get bucketSpecInvalid(): boolean { + const last = this.cfg.buckets.length - 1; + return this.cfg.buckets.some((b, i) => !b.upTo && i < last) || !!this.cfg.buckets[last]?.upTo; + } + + /** Fetch the fixed-term breakdown for the chosen as-on date and the current bucket setting. */ + loadBreakdown(): void { + const seq = ++this.loadSeq; + this.loadError = ''; + if (!parseIsoDate(this.asOnIso)) { + this.breakdown = null; + this.loadError = 'The as-on date is not valid.'; + return; + } + if (this.bucketSpecInvalid) { + this.breakdown = null; + this.loadError = 'Only the last bucket may have no end; every other bucket needs an end.'; + return; + } + this.loading = true; + this.service + .getBreakdown({ + asOnDate: this.asOnIso, + buckets: this.edgeSpec, + overdueRecentBucket: this.cfg.overdue?.recent ?? 0, + overdueOldBucket: this.cfg.overdue?.old ?? 2 + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (result) => { + if (seq !== this.loadSeq) { + return; + } + this.loading = false; + this.breakdown = result; + }, + error: (err) => { + if (seq !== this.loadSeq) { + return; + } + this.loading = false; + this.breakdown = null; + this.loadError = + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + 'No data came back. The backend needs a restart with the new endpoint.'; + } + }); + } + + /** A bucket edge or the overdue rule changed: the server must slot everything again. */ + bucketsChanged(): void { + this.changed(); + this.loadBreakdown(); + if (this.reportShown && this.selectBy === 'accounts') { + this.loadReport(); + } + } + + /** The unit as the statement's heading words it: 'Lakhs of Rupees'. */ + get unitWord(): string { + return { rupee: 'Rupees', lakh: 'Lakhs of Rupees', crore: 'Crores of Rupees' }[this.unit]; + } + + /** An amount in rupees as the chosen unit; a dash when there is nothing in it. */ + amountText(rupees: number): string { + if (!rupees) { + return '–'; + } + const divisor = this.units.find((u) => u.value === this.unit)?.divisor ?? 1; + return (rupees / divisor).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + /** The saved edits if they are intact, otherwise RBI's defaults. */ + private load(): AlmConfig { + const fallback = defaultConfig(); + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') as AlmConfig | null; + if ( + saved && + Array.isArray(saved.buckets) && + saved.buckets.length >= 2 && + Array.isArray(saved.items) && + Array.isArray(saved.limits) + ) { + const overdue = (saved.overdue ??= fallback.overdue ?? { recent: 0, old: 2 }); + // Buckets can be added and removed, so anything pointing past the last one is cut back. + const last = saved.buckets.length - 1; + overdue.recent = Math.min(overdue.recent, last); + overdue.old = Math.min(overdue.old, last); + saved.limits = saved.limits.filter((l) => l.bucket <= last); + saved.items.forEach((item) => { + item.placements = (item.placements ?? []).filter((p) => p.bucket <= last); + }); + // Lines saved before due-date lines could name their accounts take the default's, by id. + saved.items.forEach((item) => { + if (item.source === undefined) { + item.source = fallback.items.find((d) => d.id === item.id)?.source ?? ''; + } + }); + return saved; + } + } catch { + /* unreadable — start from the defaults */ + } + return fallback; + } +} diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown.config.ts b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown.config.ts new file mode 100644 index 000000000..e1b94439b --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown.config.ts @@ -0,0 +1,463 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * The Structural Liquidity Statement of an Urban Co-operative Bank (non-scheduled — 8 residual-maturity + * buckets, 1-14 days not split), laid out row for row like the statement the bank files: the same + * numbered Outflow and Inflow lines, the same buckets. + * + * Everything in this file is only the DEFAULT. It follows RBI's Urban Co-operative Banks (Asset + * Liability Management) circular of 17 September 2008 and the 2025 Directions, and where the bank's + * own filed statement slots a line differently (Interest Payable, Provisions, Other Liabilities) it + * follows the bank's statement. The screen works on a copy the bank can edit and falls back to these + * values on "Reset"; percentages in particular are a benchmark that a bank may replace with its own + * behavioural study approved by the Board / ALCO. + */ + +export type AlmSide = 'liability' | 'asset'; + +/** + * How the bucket is decided. + * - fixed: always the same bucket(s). + * - maturity: follows the due date of each account/instalment, so it spreads over the buckets. + * - split: one balance divided between buckets by share. + * - behavioural: the bank's own past-data study decides. + */ +export type AlmRule = 'fixed' | 'maturity' | 'split' | 'behavioural'; + +/** + * Which accounts feed a due-date line on their own. Empty means nothing does yet and the line has to + * be filled by hand. FD_RD is the two deposit kinds added together, as RBI's "Term Deposits" head is. + */ +export type AlmSource = 'FD' | 'RD' | 'FD_RD' | 'LOAN' | 'CCOD' | 'NPA' | ''; + +export const SOURCE_OPTIONS: { value: AlmSource; label: string }[] = [ + { value: '', label: 'None (fill by hand)' }, + { value: 'FD_RD', label: 'FD + RD accounts (by maturity)' }, + { value: 'FD', label: 'FD accounts only (by maturity)' }, + { value: 'RD', label: 'RD accounts only (by maturity)' }, + { value: 'LOAN', label: 'Term Loan accounts (by instalment)' }, + { value: 'CCOD', label: 'CC / OD loan accounts (outstanding × %)' }, + { value: 'NPA', label: 'NPA loan accounts (outstanding × %)' } +]; + +/** Sources whose accounts give one outstanding figure that the line's own percentages then divide. */ +export const SHARE_SOURCES: AlmSource[] = [ + 'CCOD', + 'NPA' +]; + +export type AlmUnit = 'day' | 'month' | 'year'; + +/** How far out a bucket reaches, counted from the as-on date. */ +export interface AlmSpan { + n: number; + unit: AlmUnit; +} + +export interface AlmBucket { + /** The column heading, as the statement prints it. */ + label: string; + /** One line a branch user can follow. */ + hint: string; + /** + * Last day the bucket takes, counted from the as-on date. Calendar months and years are used + * as they are, so '3 months' from 30 June is 30 September. Null means no upper end. + */ + upTo: AlmSpan | null; + /** + * Only on the first bucket: where it starts, when that is not day 1. Every later bucket starts where + * the one before it ends. Money due before this start still counts in the first bucket, since + * nothing may fall outside the statement. + */ + startSpan?: AlmSpan; +} + +export interface AlmPlacement { + /** Index into the bucket list. */ + bucket: number; + /** Share of the balance that goes to this bucket, 0-100; blank when the whole line goes there. */ + pct?: number | null; + /** Short name for the portion, e.g. 'Core'. */ + label?: string; +} + +export interface AlmItem { + id: string; + side: AlmSide; + /** The statement's own numbering: '1.', '3.', 'I)'. */ + ref: string; + /** 0 for a numbered head, 1 for a line under a head. */ + level: 0 | 1; + /** A heading only, such as '3. Deposits': it carries no figure of its own. */ + header?: boolean; + /** Left out of the report when false; shown when absent or true. */ + included?: boolean; + name: string; + rule: AlmRule; + /** Plain-language reason, in the words a branch manager would use. */ + reason: string; + /** True when each account falls in the bucket of its own due date. */ + spreads: boolean; + /** For a due-date line: the accounts whose maturities fill it. */ + source?: AlmSource; + placements: AlmPlacement[]; +} + +/** Prudential cap on the net cumulative negative mismatch, as a % of cumulative outflow in that bucket. */ +export interface AlmLimit { + bucket: number; + pct: number; +} + +/** + * Where an instalment or receivable that is already due and unpaid is slotted — the bank's Overdue + * Receivables rule. Both are bucket positions: 'recent' for one that fell due less than a month ago, + * 'old' for anything older. + */ +export interface AlmOverdueRule { + recent: number; + old: number; +} + +export interface AlmConfig { + buckets: AlmBucket[]; + items: AlmItem[]; + limits: AlmLimit[]; + overdue?: AlmOverdueRule; +} + +export const RULE_OPTIONS: { value: AlmRule; label: string; help: string }[] = [ + { value: 'fixed', label: 'Fixed', help: 'Always in the same bucket' }, + { value: 'maturity', label: 'By due date', help: 'Each account by its own maturity' }, + { value: 'split', label: 'Split', help: 'Balance divided by percentages' }, + { value: 'behavioural', label: 'By study', help: 'Percentages from the bank’s own study' } +]; + +/** Column headings as the filed statement prints them, with the span each one covers. */ +export const DEFAULT_BUCKETS: AlmBucket[] = [ + { label: '1 to 14 days', hint: 'Needed straight away', upTo: { n: 14, unit: 'day' } }, + { label: '15 to 28 days', hint: 'After the next two weeks', upTo: { n: 28, unit: 'day' } }, + { label: '29 days and upto 3 months', hint: 'Within this quarter', upTo: { n: 3, unit: 'month' } }, + { label: 'Over 3 months and upto 6 months', hint: 'Next quarter', upTo: { n: 6, unit: 'month' } }, + { label: 'Over 6 months and upto 1 year', hint: 'Within the year', upTo: { n: 1, unit: 'year' } }, + { label: 'Over 1 year and upto 3 years', hint: 'Medium term', upTo: { n: 3, unit: 'year' } }, + { label: 'Over 3 years and upto 5 years', hint: 'Long term', upTo: { n: 5, unit: 'year' } }, + { label: 'Over 5 years', hint: 'Permanent items', upTo: null } +]; + +/** Non-scheduled UCB: negative mismatch in 1-14 and 15-28 days must stay within 20% of outflows. */ +export const DEFAULT_LIMITS: AlmLimit[] = [ + { bucket: 0, pct: 20 }, + { bucket: 1, pct: 20 } +]; + +/** Buckets by position, so the lines below read as the bucket they mean. */ +const B = { d14: 0, d28: 1, m3: 2, m6: 3, y1: 4, y3: 5, y5: 6, over5: 7 }; + +const whole = (bucket: number): AlmPlacement[] => [{ bucket, pct: 100 }]; + +type Seed = Omit & { level?: 0 | 1; spreads?: boolean }; + +const head = (side: AlmSide, ref: string, name: string): Seed => ({ + side, + ref, + level: 0, + header: true, + name, + rule: 'fixed', + reason: '', + placements: [] +}); + +const line = ( + side: AlmSide, + ref: string, + name: string, + rule: AlmRule, + reason: string, + placements: AlmPlacement[] = [], + level: 0 | 1 = 1, + source: AlmSource = '' +): Seed => ({ side, ref, level, name, rule, reason, placements, spreads: rule === 'maturity', source }); + +const SEED: Seed[] = [ + // ── OUTFLOWS (liabilities) ── + line( + 'liability', + '1.', + 'Capital', + 'fixed', + 'This is the bank’s own money and is never paid back, so it goes in the farthest bucket.', + whole(B.over5), + 0 + ), + line( + 'liability', + '2.', + 'Reserves & Surplus', + 'fixed', + 'The bank’s own money, never paid back. Farthest bucket.', + whole(B.over5), + 0 + ), + head('liability', '3.', 'Deposits'), + line( + 'liability', + 'I)', + 'Current Deposits', + 'split', + 'A part can be withdrawn at any time (volatile), so it goes in 1-14 days. The rest is stable (core), so it goes in 1-3 years. Change the % from the bank’s own study.', + [ + { bucket: B.d14, pct: 10, label: 'Volatile' }, + { bucket: B.y3, pct: 90, label: 'Core' } + ] + ), + line( + 'liability', + 'II)', + 'Saving Bank Deposits', + 'split', + 'Same as Current. RBI’s benchmark is about 10% volatile. Change the % from the bank’s own study.', + [ + { bucket: B.d14, pct: 10, label: 'Volatile' }, + { bucket: B.y3, pct: 90, label: 'Core' } + ] + ), + line( + 'liability', + 'III)', + 'Term Deposits (FD and RD)', + 'maturity', + 'The maturity date of each FD and RD decides its bucket. An FD that is already due goes in the first bucket.', + [], + 1, + 'FD_RD' + ), + line('liability', 'IV)', 'Certificates of Deposit', 'maturity', 'By maturity date.'), + head('liability', '4.', 'Borrowings'), + line('liability', 'I.', 'Call and short Notice', 'maturity', 'The bucket of the day it has to be repaid.'), + line('liability', 'II.', 'Inter-Bank (Term)', 'maturity', 'The bucket of the day it has to be repaid.'), + line('liability', 'III.', 'Refinances', 'maturity', 'The bucket of the day it has to be repaid.'), + line('liability', 'IV.', 'Others (specify)', 'maturity', 'The bucket of the day it has to be repaid.'), + head('liability', '5.', 'Other Liabilities & Provisions'), + line('liability', 'I.', 'DD / Bills Payable', 'fixed', 'These are paid quickly, so 1-14 days.', whole(B.d14)), + line('liability', 'II.', 'Branch Adjustments', 'fixed', 'Settled between branches, usually quickly.', whole(B.d14)), + line( + 'liability', + 'III.', + 'Provisions', + 'fixed', + 'In your filed statement provisions are in 1-14 days. Change it according to the purpose of the provision.', + whole(B.d14) + ), + line( + 'liability', + 'IV.', + 'Other Liabilities', + 'fixed', + 'Amounts that are not to be paid out (like fees received in advance) go in the farthest bucket. Your filed statement has them in over 5 years.', + whole(B.over5) + ), + line( + 'liability', + '6.', + 'Unavailed portion of Cash Credit / Overdraft / Demand Loan component of Working Capital', + 'behavioural', + 'How much of the limit customers will use comes from the bank’s past history. RBI: fill the % in the buckets within 12 months.', + [], + 0 + ), + line( + 'liability', + '7.', + 'Letters of Credit / Guarantees', + 'behavioural', + 'How much of the LC / guarantee will devolve comes from the bank’s history, net of margin. Fill in the %.', + [], + 0 + ), + line('liability', '8.', 'Repos', 'maturity', 'The bucket of the day it has to be repaid.', [], 0), + line('liability', '9.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0), + line('liability', '10.', 'Swaps (Sell/Buy/Maturing forwards)', 'maturity', 'By maturity date.', [], 0), + line( + 'liability', + '11.', + 'Interest Payable', + 'fixed', + 'Interest still to be paid as per the books. Your filed statement has it in 1-14 days. RBI: the bucket of the date it is due.', + whole(B.d14), + 0 + ), + line( + 'liability', + '12.', + 'Others (specify)', + 'fixed', + 'Anything else. Your filed statement has it in over 5 years.', + whole(B.over5), + 0 + ), + + // ── INFLOWS (assets) ── + line( + 'asset', + '1.', + 'Cash', + 'fixed', + 'Cash is with the bank right now, so it is available straight away.', + whole(B.d14), + 0 + ), + line( + 'asset', + '2.', + 'Balances with RBI', + 'behavioural', + 'Balance above the CRR/SLR requirement is in 1-14 days. The required part follows the maturity of the bank’s deposits (DTL). Fill in the %.', + [], + 0 + ), + head('asset', '3.', 'Balances with other Banks'), + line( + 'asset', + 'I)', + 'Current Account', + 'fixed', + 'What can be withdrawn goes in 1-14 days. RBI: the minimum balance that has to be kept goes in 1-3 years; split it if you want.', + whole(B.d14) + ), + line( + 'asset', + 'II)', + 'Money at Call and short Notice, Term Deposits and other placements with other banks including DCCBs and SCBs', + 'maturity', + 'By the maturity date of the FD / call money placed with the bank.' + ), + line( + 'asset', + '4.', + 'Investments (including those under Repos but excluding Reverse Repos)', + 'maturity', + 'By the maturity of the bond / G-Sec, leaving out the part needed to maintain SLR.', + [], + 0 + ), + head('asset', '5.', 'Advances (Performing)'), + line( + 'asset', + 'I.', + 'Bills Purchased and Discounted (including bills under DUPN)', + 'maturity', + 'By the due date of the bill.' + ), + line( + 'asset', + 'II.', + 'Cash Credits, Overdrafts and Loans repayable on demand', + 'behavioural', + 'The part that comes back quickly goes in a short bucket; the part that always stays (core) goes in 1-3 years. Fill in the % from the bank’s study.', + [], + 1, + 'CCOD' + ), + line( + 'asset', + 'III.', + 'Term Loans', + 'maturity', + 'Each EMI goes in the bucket of its own due date, not the whole loan at once.', + [], + 1, + 'LOAN' + ), + line( + 'asset', + '6.', + 'NPAs (Advances and Investments)', + 'behavioural', + 'Sub-standard NPA in 3-5 years; doubtful and loss in the farthest bucket (net of provision). Fill in the % as the bank sees fit.', + [], + 0, + 'NPA' + ), + line( + 'asset', + '7.', + 'Fixed Assets', + 'fixed', + 'Things like buildings and furniture, which do not bring in money quickly.', + whole(B.over5), + 0 + ), + head('asset', '8.', 'Other Assets'), + line( + 'asset', + 'I.', + 'Branch Adjustments', + 'fixed', + 'Debit balance of branch adjustments, usually settled quickly.', + whole(B.d14) + ), + line('asset', 'II.', 'Leased Assets', 'maturity', 'By the date of the lease’s interim cash flows.'), + line( + 'asset', + 'III.', + 'Others', + 'fixed', + 'Things that are not real money (intangibles, non-cash) go in the farthest bucket. Your filed statement has them in over 5 years.', + whole(B.over5) + ), + line('asset', '9.', 'Reverse Repos', 'maturity', 'The bucket of the day the money comes back.', [], 0), + line('asset', '10.', 'Swaps (Buy/Sell)/Maturing forwards', 'maturity', 'By maturity date.', [], 0), + line('asset', '11.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0), + line( + 'asset', + '12.', + 'Interest Receivable', + 'fixed', + 'Interest still to be received. Your filed statement has it in over 5 years. RBI: the bucket of the date it is due.', + whole(B.over5), + 0 + ), + line( + 'asset', + '13.', + 'Export Refinance from RBI', + 'fixed', + 'Limit that has not been used yet can be drawn straight away.', + whole(B.d14), + 0 + ), + line( + 'asset', + '14.', + 'Others (specify)', + 'behavioural', + 'Anything else. Fill in the % as the bank’s study gives.', + [], + 0 + ) +]; + +/** A fresh, independent copy of the RBI defaults — callers are free to mutate it. */ +export function defaultConfig(): AlmConfig { + return { + buckets: DEFAULT_BUCKETS.map((b) => ({ ...b })), + limits: DEFAULT_LIMITS.map((l) => ({ ...l })), + // RBI (2008 UCB circular): under a month overdue is expected within 14 days; older goes to 29 days-3 months. + overdue: { recent: 0, old: 2 }, + items: SEED.map((s, i) => ({ + ...s, + level: s.level ?? 1, + id: `d${i + 1}`, + spreads: s.spreads ?? false, + placements: s.placements.map((p) => ({ ...p })) + })) + }; +} diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket.util.ts b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket.util.ts new file mode 100644 index 000000000..bd3ef6914 --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-bucket.util.ts @@ -0,0 +1,209 @@ +/** + * 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 { AlmBucket, AlmSpan } from './alm-bucket-breakdown.config'; + +/** + * The day arithmetic behind the Structural Liquidity Statement: how many days a maturity is from the + * as-on date, and which bucket that lands it in. The statement and this screen both use it, so what + * the screen shows is what the return will do. + * + * Dates are plain calendar days ('yyyy-MM-dd'), never timestamps, so the browser's time zone and + * daylight saving cannot move a maturity into the next bucket. + */ + +const MS_PER_DAY = 86_400_000; + +/** Where the first bucket starts unless told otherwise: the day after the as-on date. */ +const DAY_ONE: AlmSpan = { n: 1, unit: 'day' }; + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' +]; + +/** One bucket laid over a calendar, for a given as-on date. */ +export interface BucketWindow { + label: string; + /** First day the bucket takes. */ + from: Date; + /** Last day the bucket takes, or null when it runs on without end. */ + to: Date | null; + /** Days from the as-on date to the first / last day. */ + fromDays: number; + toDays: number | null; +} + +/** 'yyyy-MM-dd' to a local calendar date; null when it is not a real date. */ +export function parseIsoDate(iso: string | null | undefined): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? ''); + if (!m) { + return null; + } + const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return date.getFullYear() === Number(m[1]) && date.getMonth() === Number(m[2]) - 1 && date.getDate() === Number(m[3]) + ? date + : null; +} + +/** '30-Jun-2026'. */ +export function formatDate(date: Date): string { + return `${String(date.getDate()).padStart(2, '0')}-${MONTHS[date.getMonth()]}-${date.getFullYear()}`; +} + +/** Whole days from one calendar date to another; negative when `to` is earlier. */ +export function daysBetween(from: Date, to: Date): number { + const a = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate()); + const b = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()); + return Math.round((b - a) / MS_PER_DAY); +} + +/** + * The date a span reaches. Months and years are calendar months and years, and a day that does not + * exist in the target month falls back to that month's last day (31 Jan + 1 month = 28/29 Feb). + */ +export function addSpan(from: Date, span: AlmSpan): Date { + if (span.unit === 'day') { + return new Date(from.getFullYear(), from.getMonth(), from.getDate() + span.n); + } + const months = span.unit === 'year' ? span.n * 12 : span.n; + const target = new Date(from.getFullYear(), from.getMonth() + months, 1); + const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate(); + return new Date(target.getFullYear(), target.getMonth(), Math.min(from.getDate(), lastDay)); +} + +/** + * Index of the bucket a maturity falls in. A maturity on or before the as-on date is already due, so + * it is slotted in the first bucket rather than falling out of the statement. + */ +export function bucketIndexFor(asOn: Date, maturity: Date, buckets: AlmBucket[]): number { + if (!buckets.length || maturity <= asOn) { + return 0; + } + for (let i = 0; i < buckets.length; i++) { + const upTo = buckets[i].upTo; + if (!upTo || maturity <= addSpan(asOn, upTo)) { + return i; + } + } + return buckets.length - 1; +} + +/** Each bucket as a run of calendar days from the as-on date, in bucket order. */ +export function bucketWindows(asOn: Date, buckets: AlmBucket[]): BucketWindow[] { + let from = addSpan(asOn, fromSpanOf(buckets, 0) ?? DAY_ONE); + return buckets.map((b) => { + const to = b.upTo ? addSpan(asOn, b.upTo) : null; + const window: BucketWindow = { + label: b.label, + from, + to, + fromDays: daysBetween(asOn, from), + toDays: to ? daysBetween(asOn, to) : null + }; + if (to) { + from = new Date(to.getFullYear(), to.getMonth(), to.getDate() + 1); + } + return window; + }); +} + +/** 'Days 1 to 14', 'Days 29 to 92', 'Day 1827 onwards'. */ +export function dayRangeText(w: BucketWindow): string { + return w.toDays === null ? `Day ${w.fromDays} onwards` : `Days ${w.fromDays} to ${w.toDays}`; +} + +// ── The bucket ranges as a From / To table ── + +/** '14 d', '3 m', '1 y' — how a span is written in the From / To table. */ +export function spanText(span: AlmSpan): string { + return `${span.n} ${span.unit === 'day' ? 'd' : span.unit === 'month' ? 'm' : 'y'}`; +} + +/** '14 days', '1 month', '5 years' — a span in words, as the statement's headings put it. */ +function spanWords(span: AlmSpan): string { + const word = span.unit === 'day' ? 'day' : span.unit === 'month' ? 'month' : 'year'; + return `${span.n} ${word}${span.n === 1 ? '' : 's'}`; +} + +/** + * Where bucket `index` starts, as it is written in the table: the first starts on day 1, a bucket + * after one that ends in days starts the day after (14 d then 15 d), and one after a month or year + * starts "over" it (3 m then 3 m). Null for the first bucket, whose start is fixed. + */ +export function fromSpanOf(buckets: AlmBucket[], index: number): AlmSpan | null { + if (index === 0) { + return { ...(buckets[0]?.startSpan ?? DAY_ONE) }; + } + const previous = buckets[index - 1].upTo; + if (!previous) { + return null; + } + return previous.unit === 'day' ? { n: previous.n + 1, unit: 'day' } : { ...previous }; +} + +/** Move the start of bucket `index`, which is the end of the one before it. */ +export function setFromSpan(buckets: AlmBucket[], index: number, from: AlmSpan): void { + const n = Number(from.n) || 1; + if (index === 0) { + // Day 1 is the ordinary start, so it is stored as no override at all. + buckets[0].startSpan = n === 1 && from.unit === 'day' ? undefined : { n, unit: from.unit }; + return; + } + buckets[index - 1].upTo = from.unit === 'day' ? { n: Math.max(1, n - 1), unit: 'day' } : { n, unit: from.unit }; +} + +/** + * The column heading a range deserves, worded the way the filed statement words them: + * '1 to 14 days', '29 days and upto 3 months', 'Over 3 months and upto 6 months', 'Over 5 years'. + */ +export function bucketLabel(buckets: AlmBucket[], index: number): string { + const to = buckets[index]?.upTo ?? null; + const previous = index > 0 ? buckets[index - 1].upTo : null; + if (index === 0) { + const start = buckets[0].startSpan; + if (start) { + if (start.unit === 'day') { + return !to + ? `Over ${spanWords(start)}` + : to.unit === 'day' + ? `${start.n} to ${to.n} days` + : `${start.n} days and upto ${spanWords(to)}`; + } + return to ? `Over ${spanWords(start)} and upto ${spanWords(to)}` : `Over ${spanWords(start)}`; + } + if (to?.unit === 'day' && to.n === 1) { + return 'Next day'; + } + return !to ? 'Over 0 days' : to.unit === 'day' ? `1 to ${to.n} days` : `1 day and upto ${spanWords(to)}`; + } + if (!previous) { + return buckets[index].label; + } + if (previous.unit === 'day') { + const from = previous.n + 1; + if (!to) { + return `Over ${spanWords(previous)}`; + } + if (to.unit === 'day' && to.n === from) { + return `Day ${from}`; + } + return to.unit === 'day' ? `${from} to ${to.n} days` : `${from} days and upto ${spanWords(to)}`; + } + return to ? `Over ${spanWords(previous)} and upto ${spanWords(to)}` : `Over ${spanWords(previous)}`; +} diff --git a/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-maturity.service.ts b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-maturity.service.ts new file mode 100644 index 000000000..1a23634da --- /dev/null +++ b/src/app/home/dashboard/alm-bucket-breakdown-dialog/alm-maturity.service.ts @@ -0,0 +1,125 @@ +/** + * 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'; + +/** One product's total, split by bucket, in rupees. */ +export interface MaturityGroup { + key: string; + label: string; + side: 'liability' | 'asset'; + accounts: number; + total: number; + buckets: number[]; +} + +/** Loans reported apart from the date buckets, with the reason they are not slotted by date. */ +export interface NotSlotted { + key: string; + label: string; + accounts: number; + outstanding: number; + why: string; +} + +export interface MaturityBreakdown { + asOnDate: string; + bucketCount: number; + groups: MaturityGroup[]; + notSlotted: NotSlotted[]; + notes: string[]; +} + +/** What the screen asks the server for: the date, the bank's own bucket edges and its overdue rule. */ +export interface MaturityQuery { + asOnDate: string; + /** Edges like '14d,28d,3m,6m,1y,3y,5y'. */ + buckets: string; + overdueRecentBucket: number; + overdueOldBucket: number; +} + +/** One account found by a search, to be picked. */ +export interface AccountHit { + /** FD, RD or LOAN. */ + kind: 'FD' | 'RD' | 'LOAN'; + id: number; + accountNo: string; + customer: string | null; + product: string; + /** Maturity date of a deposit; null for a loan. */ + date: string | null; + /** Deposit balance, or loan principal outstanding. */ + amount: number; +} + +/** One deposit, or one unpaid loan instalment, of the accounts picked. */ +export interface AccountRow { + id: number; + accountNo: string; + customer: string | null; + product: string; + kind: string; + /** Loan instalment number, for term loans. */ + installment?: number; + /** Maturity date of a deposit, or due date of an instalment. */ + date: string | null; + /** Days from the as-on date to that date; negative once it has passed. */ + days: number | null; + status: string; + /** Position of the bucket it falls in. */ + bucket: number | null; + amount: number; + /** Why the row needs a second look, for an NPA or a cash-credit / overdraft loan. */ + note?: string | null; +} + +export interface SelectedReport { + asOnDate: string; + bucketCount: number; + count: number; + total: number; + rows: AccountRow[]; +} + +/** Fixed-term deposits and loans of the bank's customers, slotted into residual-maturity buckets. */ +@Injectable({ providedIn: 'root' }) +export class AlmMaturityService { + private http = inject(HttpClient); + + getBreakdown(query: MaturityQuery): Observable { + const params = new HttpParams() + .set('asOnDate', query.asOnDate) + .set('buckets', query.buckets) + .set('overdueRecentBucket', query.overdueRecentBucket) + .set('overdueOldBucket', query.overdueOldBucket); + return this.http.get('/almreturn/maturity-breakdown', { params }); + } + + /** Find accounts by account number or customer name; at least two characters. A kind of FD, RD or LOAN narrows it. */ + searchAccounts(q: string, kind: string): Observable<{ results: AccountHit[] }> { + let params = new HttpParams().set('q', q); + if (kind) { + params = params.set('kind', kind); + } + return this.http.get<{ results: AccountHit[] }>('/almreturn/maturity-breakdown/search', { params }); + } + + /** The day-wise breakdown of the picked accounts, given as KIND:id pairs like FD:12,RD:5,LOAN:33. */ + getSelected(accounts: string, query: MaturityQuery): Observable { + const params = new HttpParams() + .set('accounts', accounts) + .set('asOnDate', query.asOnDate) + .set('buckets', query.buckets) + .set('overdueRecentBucket', query.overdueRecentBucket) + .set('overdueOldBucket', query.overdueOldBucket); + return this.http.get('/almreturn/maturity-breakdown/selected', { params }); + } +} 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 e331d938c..dea6ded43 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 @@ -15,7 +15,7 @@ RBI Liabilities Register

Demand & Time Liabilities

-

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

+

What the bank owes, and what it must hold against it

+
-
Total Demand Liabilities
-
{{ crore(totalDemand) }}Cr
-
Payable on demand
+
Demand liabilities
+
{{ tile(totalDemand) }}
+
Repayable on demand
-
Total Time Liabilities
-
{{ crore(totalTime) }}Cr
-
Payable on maturity
+
Time liabilities
+
{{ tile(totalTime) }}
+
Repayable on maturity
-
Assets with Banking System
-
- {{ crore(totalBankingAssets) }}Cr +
Held with other banks
+ +
+ {{ tile(totalBankingAssets) }} +
+ +
+ {{ bankingAssetsNet ? 'Reduces the base below' : 'More than we owe banks — base unchanged' }}
-
↓ Deducted from NDTL
-
Net Demand & Time Liabilities (NDTL)
-
{{ crore(ndtl) }}Cr
-
After deducting banking assets
-
-
- -
CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}
-
{{ crore(crrRequired) }}Cr
-
SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}
+
NDTL — the base
+
{{ tile(ndtl) }}
+
Demand + time, net of bank balances
@@ -94,26 +97,33 @@ 02 Time Liabilities + +
@if (activeTab === 'demand') { -
Amounts payable on demand — RBI Schedule II
+
Money the bank must repay whenever a customer asks for it.
- - - - + + + + @@ -167,17 +177,17 @@ @if (activeTab === 'time') { -
Amounts payable after a specified period — RBI Schedule III
+
Money the bank repays only when the deposit matures.
# Liability ItemAccount CodeOpening (₹ Cr)Current (₹ Cr)Change (₹ Cr)ItemOpeningCurrentChange Status
- - - - + + + + @@ -231,17 +241,20 @@ @if (activeTab === 'assets') { -
Deducted from gross liabilities to arrive at NDTL — RBI Master Circular
+
+ What the bank keeps with other banks. This comes off what it owes them — and only off that, so where it is the + larger of the two the base is left as it is. +
# Liability ItemAccount CodeOpening (₹ Cr)Current (₹ Cr)Change (₹ Cr)ItemOpeningCurrentChange Status
- - - - + + + + @@ -275,7 +288,12 @@ @@ -289,20 +307,18 @@ @if (activeTab === 'slr') { -
- SLR = (Liquid Assets / NDTL) × 100 — RBI Sec 24(2A) of Banking Regulation Act, 1949 -
+
Cash, bank balances and securities the bank holds against its SLR requirement.
# Asset ItemAccount CodeOpening (₹ Cr)Current (₹ Cr)Change (₹ Cr)ItemOpeningCurrentChange Status
- Total Assets with Banking System ( Deducted from NDTL ) + Total Assets with Banking System + {{ + bankingAssetsNet + ? '( Deducted from NDTL )' + : '( Netted against item I, which is smaller — no deduction )' + }} {{ totalOf(bankingAssets, 'openingValue') }} {{ totalOf(bankingAssets, 'currentValue') }}
- + - - - + + + @@ -348,119 +364,141 @@
# Liquid AssetAccount CodeItem CategoryOpening (₹ Cr)Current (₹ Cr)Change (₹ Cr)OpeningCurrentChange Status
- +
-
SLR Computation — (Liquid Assets / NDTL) × 100
+
How the SLR ratio is worked out
-
Liquid Assets
-
₹13,238.80
-
6 asset categories
+
Assets we hold
+
{{ money(totalLiquidAssets) }}
+
{{ liquidAssets.length }} categories
÷
-
NDTL
-
₹69,221.30
-
Net Demand & Time
-
-
×
-
-
100
-
100
-
 
+
NDTL base
+
{{ money(baseNdtl) }}
+
as at {{ baseDateStr || '—' }}
=
-
Actual SLR
+
SLR held
{{ slrActualPercent }}%
-
Min required: {{ slrRate }}%
+
minimum {{ slrRate }}%
- -
-
-
SLR Compliance Status
-
- Actual: {{ slrActualPercent }}% · Required: {{ slrRate }}.00% · NDTL: ₹{{ - ndtl | number: '1.2-2' - }} - Cr -
-
-
-
-
SLR Required ({{ slrRate }}%)
-
₹{{ slrRequired | number: '1.2-2' }} Cr
-
-
-
Liquid Assets Held
-
₹{{ totalLiquidAssets | number: '1.2-2' }} Cr
-
-
-
Surplus
-
₹{{ slrSurplus | number: '1.2-2' }} Cr
-
-
-
+ } - -
-
- NDTL Computation — Formula Breakdown · Data as of {{ reportingDateStr }} + +
+
+
Are we meeting the requirement?
+
+ Charged on the NDTL of {{ money(baseNdtl) }} as at {{ baseDateStr || reportingDateStr }} +
-
-
-
Demand Liabilities
-
₹{{ totalDemand | number: '1.2-2' }}
-
16 line items
-
-
+
-
-
Time Liabilities
-
₹{{ totalTime | number: '1.2-2' }}
-
13 line items
-
-
−
-
-
Assets with Banking
-
₹{{ totalBankingAssets | number: '1.2-2' }}
-
3 line items
-
-
=
-
-
NDTL
-
₹{{ ndtl | number: '1.2-2' }}
-
CRR & SLR base
+ +
+ @for (r of positionRatios(); track r.name) { +
+ +
{{ r.name }}{{ r.rate === null ? ' — rate not set' : ' @ ' + r.rate + '%' }}
+
{{ money(r.required) }}
+
+ held {{ money(r.maintained) }} + + · {{ r.met ? '+' + money(r.surplus) : 'short ' + money(r.shortfall) }} + +
+
+ } +
+
Total Reserve
+
{{ money(totalReserve) }}
+
CRR + SLR required
- -
-
-
CRR & SLR Reserve Requirements — RBI Computation on NDTL
-
- Based on NDTL of ₹{{ ndtl | number: '1.2-2' }} Cr · Data as of {{ reportingDateStr }} (45-day lag) -
+ + @if (activeTab === 'held') { +
+ Where the cash reserve is held, with each account's balance on the business date + {{ position?.onDate || '—' }}. The requirement below is charged on {{ position?.baseDate || '—' }}, a fortnight back — RBI's lag is a rule, so today's own liabilities settle nothing yet.
-
-
-
CRR @ {{ crrRate }}.00%
-
₹{{ crrRequired | number: '1.2-2' }} Cr
+ + @if (position) { + +
+ @for (r of positionRatios(); track r.name) { +
+
{{ r.name }}{{ r.rate === null ? '' : ' @ ' + r.rate + '%' }}
+
+ Required{{ money(r.required) }} Maintained{{ money(r.maintained) }} + {{ r.met ? 'Surplus' : 'Shortfall' }} + {{ money(r.met ? r.surplus : r.shortfall) }} +
+
+ }
-
-
SLR @ {{ slrRate }}.00%
-
₹{{ slrRequired | number: '1.2-2' }} Cr
+ +
+ + + + + + + + + + + + @for (a of position.accounts; track a.glAccountId) { + + + + + + + + } + +
#AccountForm 1 headBalance today 
{{ $index + 1 }} +
{{ a.name }}
+
{{ a.glCode }}
+
+ {{ a.item }} · {{ a.head }} + + @if (!a.countsTowardMaintained) { + not counted on the return + } + {{ money(a.balance) }} + +
-
-
Total Reserve
-
₹{{ totalReserve | number: '1.2-2' }} Cr
-
-
-
+ } + } + @if (crrSlr.crrGap) { +
{{ crrSlr.crrGap.text }}
+ }
SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }} {{ crrSlr.slrRequired }}
+ @if (crrSlr.slrGap) { +
{{ crrSlr.slrGap.text }}
+ }
diff --git a/src/app/home/dashboard/dashboard.component.scss b/src/app/home/dashboard/dashboard.component.scss index 1cde1703d..45b183bd3 100644 --- a/src/app/home/dashboard/dashboard.component.scss +++ b/src/app/home/dashboard/dashboard.component.scss @@ -899,3 +899,17 @@ $purple: #7c3aed; text-align: center; } } + +// What is held against a requirement, under the requirement itself. Surplus is quiet; a shortfall is +// red, because on a compliance card it is the one line that must not be read past. +.metric-gap { + margin: -2px 0 8px; + text-align: right; + font-size: 11px; + color: #137333; +} + +.metric-gap--short { + color: #c5221f; + font-weight: 700; +} diff --git a/src/app/home/dashboard/dashboard.component.ts b/src/app/home/dashboard/dashboard.component.ts index c0605e700..90ad070a7 100644 --- a/src/app/home/dashboard/dashboard.component.ts +++ b/src/app/home/dashboard/dashboard.component.ts @@ -61,8 +61,19 @@ export class DashboardComponent implements OnInit { slrRate: number | null; crrRequired: string; slrRequired: string; + /** Surplus, or the shortfall, per ratio. A shortfall is its own amount, not a negative surplus. */ + crrGap: { text: string; short: boolean } | null; + slrGap: { text: string; short: boolean } | null; status: 'ok' | 'short' | 'unknown'; - } = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' }; + } = { + crrRate: null, + slrRate: null, + crrRequired: '—', + slrRequired: '—', + crrGap: null, + slrGap: null, + status: 'unknown' + }; /** Array of all user activities */ userActivity: string[]; @@ -93,8 +104,27 @@ export class DashboardComponent implements OnInit { /** 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`; + // Rupees, not crore. Rounding to crore on a bank this size collapsed the figures to "₹2 Cr" + // and "₹13 Cr" — two requirements a crore apart both read the same, and a tile a treasury desk + // glances at said nothing it could act on. + // Scaled to the Indian crore/lakh so the tile holds its figure however large the bank grows. + // Plain rupees ran to nineteen characters on a seventy-crore requirement and overflowed the + // card; rounding to crore outright collapsed two requirements a crore apart to the same "₹2 + // Cr". This keeps four or five characters and two decimals of precision at any scale. + const money = (v: number | null | undefined): string => { + if (v === null || v === undefined) { + return '—'; + } + const abs = Math.abs(v); + const sign = v < 0 ? '-' : ''; + if (abs >= 1e7) { + return `${sign}₹${(abs / 1e7).toLocaleString('en-IN', { maximumFractionDigits: 2 })} Cr`; + } + if (abs >= 1e5) { + return `${sign}₹${(abs / 1e5).toLocaleString('en-IN', { maximumFractionDigits: 2 })} L`; + } + return `${sign}₹${Math.round(abs).toLocaleString('en-IN')}`; + }; // 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 @@ -107,8 +137,8 @@ export class DashboardComponent implements OnInit { } 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]); + this.crrSlr.crrRequired = money(data?.partB?.required?.[latest]); + this.crrSlr.slrRequired = money(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. @@ -116,6 +146,19 @@ export class DashboardComponent implements OnInit { const crrNeed = data?.partB?.required?.[latest]; const slrHeld = data?.partC?.maintained?.[latest]; const slrNeed = data?.partC?.required?.[latest]; + // Each ratio's own standing, under its requirement: surplus where it is met, and the + // shortfall — in red, named as one — where it is not. A single "Shortfall" tag said the bank + // was short without saying which ratio or by how much, which is the first thing asked. + const gap = (held: number | null | undefined, need: number | null | undefined) => { + if (held === null || held === undefined || need === null || need === undefined) { + return null; + } + const short = held < need; + return { text: `${short ? 'Short' : 'Surplus'} ${money(Math.abs(held - need))}`, short }; + }; + this.crrSlr.crrGap = gap(crrHeld, crrNeed); + this.crrSlr.slrGap = gap(slrHeld, slrNeed); + if ([ crrHeld, crrNeed, diff --git a/src/app/kyc-management/kyc-management.component.html b/src/app/kyc-management/kyc-management.component.html index 893e1e000..7c9af3697 100644 --- a/src/app/kyc-management/kyc-management.component.html +++ b/src/app/kyc-management/kyc-management.component.html @@ -12,22 +12,17 @@ - - +
-
-
-
47
+
+ + + + + +
+ +
+
+
{{ expiringRows.length }}
Expiring in 30 days
-
▲ 12 this month
+
Re-KYC falls due soon
-
-
23
-
Debit Frozen
-
Action required
+
+
{{ expiryTotals.grace }}
+
Overdue — Grace Period
+
KYC lapsed, still operative
-
-
8
+
+
{{ expiryTotals.partialFreeze }}
+
Debit Freeze
+
Debits blocked
+
+
+
{{ expiryTotals.inoperative }}
Inoperative
-
Escalating
+
KYC lapsed {{ expiryPolicy.inoperativeDays }}d+
-
-
3
+
+
{{ dormancyTotals.inactive }}
+
Inactive
+
+ No customer txn {{ dormancyPolicy.inactiveDays }}d +
+
+
+
{{ dormancyTotals.dormant }}
Dormant
-
RBI reportable
+
No customer txn {{ dormancyPolicy.dormantDays }}d
-
-
18
-
Notices Today
-
SMS + Email
+
+
{{ dormancyTotals.escheat }}
+
Escheat (DEAF)
+
RBI DEAF remittance
-
- - - - - -
+ @if (alertFiltersActive) { +
+
+
{{ alertFilter ? alertLabels[alertFilter] : 'Matching accounts' }}
+ {{ alertList.length }} accounts · Reset to close + +
+ + + + + + + + + + + + + + + + + @for (r of alertList; track $index) { + + + + + + + + + + + + + } @empty { + + + + } + +
+ + CustomerAccountBranchStateDateDaysBalanceNext stageAction
+ + +
{{ r.name }}
+
{{ r.account }}{{ r.branch }} + {{ r.stage }} + {{ r.since | date: 'dd MMM yyyy' }}{{ r.days }} {{ r.daysLabel }} + {{ r.balance === null ? '—' : '₹' + (r.balance | number: '1.2-2') }} + +
{{ r.next }}
+ @if (r.nextDate) { +
{{ r.nextDate | date: 'dd MMM yyyy' }}
+ } +
+
+ + @if (r.stageKey === 'ESCHEAT') { + + } @else { + + } +
+
+ {{ statusLoading ? 'Loading…' : 'No account matches these filters.' }} +
+
+ }
-
-
Accounts Requiring Action
- Showing {{ alertRows.length }} of 81 · Click row for client details +
Full reports
+ Acknowledge outreach, record DEAF remittance and refund, branch and balance filters, Excel export +
+
+ +
- - - - - - - - - + + - @for (row of alertRows; track row.id) { - + + + + + + + + + + + + + + + + + + + + + + + + + +
CustomerRiskExpiry DateDays LeftNoticesStatePending ActionNext StageRuleWhat happens
Re-KYC overdue, first {{ expiryPolicy.graceDays }} daysNothing is restricted; reminders go out
Re-KYC overdue, after {{ expiryPolicy.graceDays }} daysDebits stopped; credits still accepted
Re-KYC overdue, after {{ expiryPolicy.inoperativeDays }} daysMarked inoperative; only {{ alwaysAllowed.join(', ') }} still go through
No customer transaction for {{ dormancyPolicy.inactiveDays }} daysInactive — flagged for the branch, nothing restricted
No customer transaction for {{ dormancyPolicy.dormantDays }} daysDormant / inoperative — debits stopped; revived by re-KYC
No customer transaction for {{ dormancyPolicy.escheatDays }} daysEscheat — balance remitted to RBI DEAF
+
+ + @if (showExpiryReport) { +
+ } + @if (showDormancyReport) { +
+ } +
+ } + + + @if (activeSection === 'notif') { +
+
+ Notification Scheduler +
+ + +
+
+ +
+
+
{{ schedulerRows.length }}
+
Customers in scope
+
Expiring in 30 days + lapsed
+
+
+
{{ schedulerDueCount }}
+
Next notice due
+
SMS, Email or WhatsApp to send
+
+
+
{{ schedulerNeverContacted }}
+
Never contacted
+
No notice on record
+
+
+
{{ schedSelected.size }}
+
Selected
+
Ready to dispatch
+
+
+ +
+ + +
+ +
+
+
Notice queue — sent in the background
+ + Waiting {{ queueCounts['PENDING'] + queueCounts['SENDING'] }} · Sent {{ queueCounts['SENT'] }} · Failed + {{ queueCounts['FAILED'] }} · Cancelled {{ queueCounts['CANCELLED'] }} + + +
+ @if (queueError) { +
{{ queueError }}
+ } + + + + + + + + + + + + + + + @for (g of queueGroups; track g.date) { + + + + + + + + + + } @empty { + + + + } + +
DateCustomersNoticesWaitingSentFailedCancelledAction
-
{{ row.name }}
-
{{ row.account }}
+ {{ g.date | date: 'dd MMM yyyy' }} +
+ {{ g.customers }} + {{ g.rows.length }} + {{ g.waiting }} + + {{ g.sent }} + + {{ g.failed }} + + {{ g.cancelled }} + + +
+ {{ + queueLoading + ? 'Loading…' + : 'Nothing has been queued yet. Select customers on the Alert Dashboard and use Notify selected.' + }} +
+
+ +
+
+
Escalation ladder — one channel at a time
+ The next rung is the first one not yet done. Call and visit rungs are recorded from Notify. +
+ + + + + + + + + + + + + + @for (s of schedulerRows; track s.row.clientId) { + + - - + + - - - + } @empty { + + } @@ -171,525 +520,113 @@ } - - @if (activeSection === 'notif') { -
-
- Notification Scheduler -
- - -
-
- -
-
-
-
-
Escalation Ladder — one channel at a time
- - {{ reKycCompletedCount }} of {{ reKycSteps.length }} dispatched · Expiry: 30 Apr 2027 · Window: 31 Mar – - 30 Apr - -
-
-
- - @for (s of reKycSteps; track s.step; let i = $index) { -
-
-
{{ s.step }}
-
-
-
- @if (isFirstOfNotice(i)) { -
Notice {{ s.notice }}
- } -
Step {{ s.step }} — {{ s.channel }}
-
- {{ s.date }} · {{ s.offset }} · - {{ reKycStatusLabel(s.status) }} -
-
- {{ s.channel }} - {{ s.detail }} -
- @if (s.status === 'PENDING') { -
- - -
- } -
-
- } -
-
-
!
-
-
-
-
Debit Freeze — if no action
-
30 April 2027 · Auto-triggered on expiry
-
- Auto-triggered - Credits allowed - Debits blocked -
-
-
-
-
-
!
-
-
-
-
Inoperative — +60 days
-
30 June 2027 · No compliance
-
All txns blocked
-
-
-
-
!
-
-
Dormant — +150 days
-
30 September 2027
-
- Dormant - RBI reporting -
-
-
-
-
-
- -
-
-
-
-
Channel Sequence
-
- -
- @for (s of reKycSteps; track s.step) { -
-
{{ s.step }}. {{ s.channel }}
-
{{ s.detail }}
-
- {{ reKycStatusLabel(s.status) }} -
-
- } -
-
- -
-
-
-
India Post Notice
-
-
-
-
Dispatch Date
-
10 April 2027
-
-
-
Reg. Post Docket No.
-
RP-BPL-20270410-443
-
-
-
Status
-
In Transit
-
- -
-
- -
-
-
-
Home Visit
-
-
-
Scheduled Date
- -
Officer
- - -
-
-
-
-
- } - - + @if (activeSection === 'audit') {
Audit Inventory
- -
- + + - + + + + + + - - - + + +
-
-
KYC Notice Log
- Click row for client details +
Notices sent, day by day
+ {{ auditDays.length }} day(s) · {{ auditNoticeTotal }} notice(s) · click a day to see who was sent + what
+ @if (auditError) { +
{{ auditError }}
+ }
+ + CustomerKYCLadderNext stepLast contactAction
+ @if (s.next?.sends) { + + } - {{ row.riskLabel.split(' ')[0] }} +
{{ s.row.clientName }}
+
{{ s.row.accountNo }}
{{ row.expiry }} -
- {{ row.daysLeft > 0 ? row.daysLeft : '—' }} -
-
-
+
{{ s.row.expiryDate | date: 'dd MMM yyyy' }}
+
+ {{ + s.row.daysOverdue < 0 ? -s.row.daysOverdue + ' days left' : s.row.daysOverdue + ' days overdue' + }}
- {{ row.noticesSent }}/3 +
+ @for (st of s.steps; track st.label) { + + {{ st.state === 'done' ? '✓ ' : '' }}{{ st.label }} + + } +
- {{ stateLabel(row.state) }} + {{ s.next ? s.next.label : 'Ladder complete' }}{{ s.row.lastContactDate ? (s.row.lastContactDate | date: 'dd MMM yyyy') : 'Never' }} + @if (s.next?.sends) { + + } @else if (s.next) { + + } - {{ row.noticePending }} - - {{ row.nextStage }} - - +
+ {{ statusLoading ? 'Loading…' : 'No customer is expiring or lapsed.' }}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#Account / CustomerNoticeChannelDispatchedReference / DocketDeliveryClient ActionOfficer
001 -
Rajesh K. Sharma
-
ACC-BPL-2024-00847
-
Notice 1SMS + Email31 Mar 2027MSG-2027-0331-001✓ DeliveredAwaitedScheduler
002 -
Rajesh K. Sharma
-
ACC-BPL-2024-00847
-
Notice 2Reg. Post + IVR10 Apr 2027RP-BPL-20270410-443In TransitAwaitedScheduler
003 -
Priya Malhotra
-
ACC-BPL-2021-00231
-
Notice 1SMS + Email10 Mar 2027MSG-2027-0310-003✓ DeliveredNo ResponseScheduler
004 -
Priya Malhotra
-
ACC-BPL-2021-00231
-
Notice 2Reg. Post20 Mar 2027RP-BPL-20270320-091✓ DeliveredNo ResponseScheduler
005 -
Priya Malhotra
-
ACC-BPL-2021-00231
-
Notice 3Home Visit30 Mar 2027VIS-BPL-20270330-012✓ DoneRefusedA. Mishra
006 -
Suresh Patel
-
ACC-BPL-2019-00112
-
Notice 3Video KYC5 Feb 2027VKYC-2027-0205-007No ResponseNo ActionSystem
-
- Showing 6 of 12 -
- - -
-
-
-
- } - - - @if (activeSection === 'status') { -
-
- Account Status Management - -
- -
-
-
Active
-
1,847
-
KYC valid
-
-
-
Alert Window
-
47
-
Expiring < 30d
-
-
-
Debit Freeze
-
23
-
Debits blocked
-
-
-
Inoperative
-
8
-
All txns blocked
-
-
-
Dormant
-
3
-
RBI reportable
-
-
- -
-
-
-
-
Auto-Transition Rules
-
-
-
-
- Active → Debit FreezeEnabled -
-
- KYC expires + 3 notices sent + no response → debits blocked, credits allowed -
-
- Window: 31 Mar – 30 Apr - 3/3 notices -
-
-
-
- Debit Freeze → InoperativeEnabled -
-
+60 days from expiry, still unresolved → all transactions blocked
-
Apr 30 – Jun 30
-
-
-
- Inoperative → DormantEnabled -
-
+150 days from expiry → dormant, RBI Dormant Account reporting triggered
-
- Jun 30 – Sep 30 - RBI notification -
-
-
-
- -
-
-
-
Manual Override
- Maker-Checker -
-
-
Account
- -
Current State
-
Debit Freeze
-
Override Action
- -
Reason
- -
- - -
-
-
⚠ Maker-Checker Required
-
- All manual changes require second officer approval and are logged in audit inventory. -
-
-
-
-
- -
-
-
-
Recent State Transitions
- Click row for client details -
- - - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + @for (d of auditDays; track d.date) { + + + + + + + + + + + } @empty { + + + + }
AccountFromToTrigger DateByActionCustomersNoticesDeliveredFailedNo contact detailRecorded (call / visit)Action
-
Priya Malhotra
-
ACC-BPL-2021-00231
-
ActiveDebit FreezeAuto — KYC Expired10 Apr 2027Scheduler - -
-
Suresh Patel
-
ACC-BPL-2019-00112
-
Debit FreezeInoperativeAuto — 60d elapsed15 May 2027Scheduler - -
-
Kavitha Nair
-
ACC-BPL-2018-00088
-
InoperativeDormantAuto — 150d elapsed1 Jun 2027Scheduler - -
+ {{ d.date | date: 'dd MMM yyyy' }} + + {{ d.customers }} + {{ d.notices }} + {{ d.delivered }} + + {{ d.failed }} + + {{ + d.noContact + d.notConfigured + }} + + {{ d.recorded }} + + +
+ {{ auditLoading ? 'Loading…' : 'No notice matches these filters.' }} +
@@ -976,447 +913,383 @@ }
- - @if (modalOpen && current) { -
-
+ + @if (auditDay) { +
+
-
{{ current.av }}
-
{{ current.name }}
-
{{ current.meta }}
+
Notices sent on {{ auditDay | date: 'dd MMM yyyy' }}
+
+ {{ auditDayTotal }} notice(s) · click a row for the message the customer received +
- {{ stateLabel(current.state) }} - {{ current.riskLabel }} -
✕
+
✕
-
-
- - - - - -
- - @if (modalTab === 'overview') { -
Compliance Journey
-
- @for (step of getStepperState(current); track step.label) { -
-
{{ stepIcon(step.state) }}
-
{{ step.label }}
-
- } -
- -
-
⚡
-
-
{{ stateLabel(current.state) }} — {{ current.daysLeft }} days to expiry
-
- Next pending: {{ current.noticePending }}. Next stage: {{ current.nextStage }}. -
-
-
- -
-
-
{{ current.noticesSent }}
-
Notices Sent
-
-
-
{{ noticesPending(current) }}
-
Notices Pending
-
-
-
{{ current.actionTaken.length }}
-
Actions Taken
-
-
-
{{ current.pendingActions.length }}
-
Pending Actions
-
-
- -
-
-
KYC Information
-
-
-
PAN
-
{{ current.kyc.pan }}
-
-
-
PAN Status
-
- {{ current.kyc.panStatus }} -
-
-
-
Aadhaar
-
{{ current.kyc.aadhaar }}
-
-
-
Aadhaar Status
-
- {{ current.kyc.aadhaarStatus }} -
-
-
-
KYC Validity
-
{{ current.kyc.validity }}
-
-
-
Expiry Date
-
{{ current.expiry }}
-
-
-
-
-
Contact Details
-
-
-
Mobile
-
{{ current.contact.mobile }}
-
-
-
Email
-
{{ current.contact.email }}
-
-
-
Address
-
{{ current.contact.address }}
-
-
-
Branch
-
{{ current.contact.branch }}
-
-
-
-
+
+ @if (auditDayError) { +
{{ auditDayError }}
} - - - @if (modalTab === 'timeline') { -
Notice & Escalation Timeline
-
- @for (item of current.actionTaken; track $index) { -
-
✓
-
-
{{ item }}
-
Completed
-
-
- } - @for (item of current.pendingActions; track $index) { -
-
!
-
-
{{ item }}
-
Pending action
-
-
- } -
- } - - - @if (modalTab === 'notices') { -
All Notices Dispatched
- - - - - - - - - - +
#NoticeChannelDateReferenceDeliveryResponse
+ + + + + + + + + + + + + + @for (r of auditDayRows; track r.id; let i = $index) { + + + + + + + + + - - - @for (n of current.notices; track n.ref; let i = $index) { + @if (auditExpanded === r.id) { - - - - - - - + } - @if (noticesPending(current) > 0) { + } @empty { + + + + } + +
#Customer / accountChannelTimeSent toReferenceDeliveryOfficer
{{ auditDayPage * auditPageSize + i + 1 }} +
{{ r.clientName }}
+
{{ r.accountNo }}
+
{{ auditChannelLabels[r.channel] || r.channel }}{{ r.contactedAt | date: 'HH:mm' }}{{ r.destinationMasked || '—' }}{{ r.providerReference || '—' }} + {{ + auditStatusLabels[r.deliveryStatus] || r.deliveryStatus + }} + {{ r.officerName || '—' }}
{{ i + 1 }} - {{ - n.no - }} - {{ n.channel }}{{ n.date }} - {{ n.ref }} - - {{ n.delivery }} - - {{ n.response }} + +
Note
+
{{ r.notes }}
+ @if (r.messageBody) { +
Message sent to the customer
+
{{ r.messageBody }}
+ } + @if (r.failureReason) { +
Failure reason
+
{{ r.failureReason }}
+ }
+ {{ auditDayLoading ? 'Loading…' : 'No notice matches these filters on this day.' }} +
+
+ + +
+
+ } + + + @if (queueDetail; as d) { +
+
+
+
+
Notices queued on {{ d.date | date: 'dd MMM yyyy' }}
+
+ {{ d.customers }} customer(s) · {{ d.rows.length }} notice(s) — Waiting {{ d.waiting }} · Sent + {{ d.sent }} · Failed {{ d.failed }} · Cancelled {{ d.cancelled }} +
+
+
✕
+
+ +
+ + + + + + + + + + + + + + + + + @for (q of d.rows; track q.id; let i = $index) { + + + + + + + + + + + + + } + +
#CustomerAccountNoticeChannelQueuedSend atStatusResultAction
{{ i + 1 }} +
{{ q.clientName }}
+
{{ q.accountNo }}{{ q.stageLabel || (q.noticeType === 'DORMANCY' ? 'Account notice' : 'Re-KYC notice') }}{{ q.channel }}{{ q.createdAt | date: 'HH:mm' }}{{ q.scheduledFor | date: 'dd MMM, HH:mm' }} + + {{ + q.status === 'PENDING' + ? 'Waiting' + : q.status === 'SENDING' + ? 'Sending' + : q.status === 'SENT' + ? 'Sent' + : q.status === 'FAILED' + ? 'Failed' + : 'Cancelled' + }} + + @if (q.attempts > 1 && q.status === 'PENDING') { +
retry {{ q.attempts }}
+ } +
{{ q.resultMessage || '—' }} + @if (q.status === 'PENDING') { + + } @else if (q.status === 'FAILED') { + + } +
+
+ + +
+
+ } + + + @if (bulkOpen) { +
+
+
+
+
Notify selected customers
+
+ {{ bulkDone && bulkQueued ? bulkQueued.queued : bulkTargets.length }} customers +
+
+
✕
+
+ +
+ @if (!bulkDone && !bulkSending) { +
+ + +
+ + A notice is sent to each customer once, even if several of their accounts are ticked. +
+
+ + +
+ @if (bulkError) { +
{{ bulkError }}
+ } + @for (g of bulkNoticeSummary; track g.label) { +
+ {{ g.count }} customer(s): {{ g.label }} — worded for the stage each one is at. +
+ } +
+
+ + +
+
+ + +
+ @if (bulkTargets.length > bulkLimit) { +
+ {{ bulkTargets.length }} customers selected — send at most {{ bulkLimit }} at a time. +
+ } + } @else { + @if (bulkSending) { +
Queuing…
+ } @else if (bulkQueued) { +
+ + {{ bulkQueued.queued }} notice(s) queued — they are sent in the background. + @if (bulkQueued.skipped) { + {{ bulkQueued.skipped }} already waiting in the queue, so not queued again. + } + Follow them in the Notification Scheduler tab. +
+ } + } +
+ + +
+
+ } + + + @if (schedOpen) { +
+
+
+
+
Dispatch next notice
+
+ {{ schedDone && schedQueued ? schedQueued.queued : schedTargets.length }} customers +
+
+
✕
+
+ +
+ @if (!schedDone && !schedSending) { + + + + + + + + + @for (t of schedTargets; track t.row.clientId) { - + }
CustomerGoes out as
- ⏳ {{ noticesPending(current) }} notice(s) still pending dispatch + + {{ t.row.clientName }} +
{{ t.row.accountNo }}
{{ t.next?.label }}
- } - - - @if (modalTab === 'evidence') { - @if (!current.evidence) { -
-
📄
-
No evidence collected yet for this client.
-
Evidence is captured during branch visits or video KYC sessions.
-
- } @else { -
- {{ current.evidence.type === 'visit' ? 'Home Visit Evidence' : 'Video KYC Session Log' }} -
-
-
-
Date
-
{{ current.evidence.date }}
-
-
-
Officer / Agent
-
{{ current.evidence.officer }}
-
- @if (current.evidence.type === 'visit') { -
-
GPS Coordinates
-
{{ current.evidence.gps }}
-
-
-
Location
-
{{ current.evidence.location }}
-
+
+ + +
+
+ + +
+ @if (schedError) { +
{{ schedError }}
+ } + @if (schedTargets.length > bulkLimit) { +
Send at most {{ bulkLimit }} at a time.
+ } + } @else { + @if (schedSending) { +
Queuing…
+ } @else if (schedQueued) { +
+ + {{ schedQueued.queued }} notice(s) queued — they are sent in the background and appear in the queue + below. + @if (schedQueued.skipped) { + {{ schedQueued.skipped }} already waiting, so not queued again. } -
-
Reference / Docket
-
{{ current.evidence.docket || current.evidence.ref }}
-
-
-
Outcome
-
No Compliance
-
-
- @if (current.evidence.type === 'visit') { -
Map Placeholder
-
- {{ current.evidence.gps }} -
{{ current.evidence.location }}
-
-
Photo Evidence
-
Visit photo uploaded · Tap to view
- } -
Officer Notes
-
{{ current.evidence.outcome }}
-
- -
} } +
- - @if (modalTab === 'action') { -
Available Actions
-
- @for (action of current.pendingActions; track action) { -
-
-
{{ action }}
-
Initiate via available channels
-
- -
- } -
+
} - - @if (homeVisitOpen && current) { -
-
-
-
{{ current.av }}
-
-
Schedule Home Visit
-
{{ current.name }} · {{ current.account }}
-
-
✕
-
- -
-
-
Visit Address
-
{{ current.name }}
-
{{ current.contact.address }}
-
Branch: {{ current.contact.branch }}
-
- -
- - -
- -
- - -
- -
- - -
-
- - -
-
- } - - - @if (vkycOpen && current) { -
-
-
-
{{ current.av }}
-
-
Send Video KYC Link
-
{{ current.name }} · {{ current.account }}
-
-
✕
-
- -
-
-
Customer Contact
-
{{ current.name }}
-
- {{ current.contact.mobile }} -
-
- {{ current.contact.email }} -
-
- -
- -
- - - -
-
- -
- - -
- -
- - -
Default: 48 hours (RBI Video-based KYC norms)
-
- -
- - -
- -
- - -
-
- - -
-
- } - @if (contactDialogOpen && current) {
@@ -1459,6 +1332,15 @@ } @else {
Records an action you already took. Nothing is sent to the customer.
} + @if (selectedChannelSends && contactPreview) { +
+ {{ contactPreview.subject }} + {{ contactPreview.body }} +
+ } @if (selectedChannelUnavailable) {
This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on @@ -1468,12 +1350,8 @@
- - + +
diff --git a/src/app/kyc-management/kyc-management.component.scss b/src/app/kyc-management/kyc-management.component.scss index 697f974b9..c39b27c06 100644 --- a/src/app/kyc-management/kyc-management.component.scss +++ b/src/app/kyc-management/kyc-management.component.scss @@ -108,7 +108,6 @@ .km-main { padding: 20px 24px; - max-width: 1400px; } .km-page-head { @@ -1778,3 +1777,7 @@ border-radius: 2px; } } + +.km-rep { + margin-top: 24px; +} diff --git a/src/app/kyc-management/kyc-management.component.ts b/src/app/kyc-management/kyc-management.component.ts index 8da0f15d6..fb4fd1839 100644 --- a/src/app/kyc-management/kyc-management.component.ts +++ b/src/app/kyc-management/kyc-management.component.ts @@ -6,12 +6,39 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { ClientsService } from '../clients/clients.service'; import { exportWorkbook, ExportSheetSpec } from '../cashier/cashier-export'; +import { KycExpiryComponent } from '../kyc-expiry/kyc-expiry.component'; +import { DormancyReportComponent } from '../dormancy-report/dormancy-report.component'; +import { firstValueFrom, forkJoin, from, of } from 'rxjs'; +import { catchError, concatMap, map } from 'rxjs/operators'; +import { toIsoDate } from 'app/core/utils/api-date'; +import { businessTodayIso } from 'app/core/utils/business-today'; +import { + ALWAYS_ALLOWED, + KYC_EXPIRY_POLICY, + KycContactRow, + KycExpiryRow, + KycExpirySummary, + buildExpiryRow, + channelLabel, + expirySummary, + sortWorklist +} from '../kyc-expiry/kyc-expiry.compute'; +import { + DORMANCY_POLICY, + DormancyRow, + DormancySummary, + dormancySummary, + nextStage, + sortByIdle, + stageFromSubStatus +} from '../dormancy-report/dormancy-report.compute'; interface KmNotice { no: string; @@ -106,461 +133,23 @@ interface KmClient { imports: [ ...STANDALONE_SHARED_IMPORTS, FormsModule, - FaIconComponent + FaIconComponent, + KycExpiryComponent, + DormancyReportComponent ] }) -export class KycManagementComponent implements OnInit { +export class KycManagementComponent implements OnInit, OnDestroy { private clientsService = inject(ClientsService); - activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts'; + private router = inject(Router); + activeSection: 'alerts' | 'status' | 'notif' | 'audit' | 'exceptions' | 'expiry' | 'dormancy' = 'alerts'; - modalOpen = false; - modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview'; + /** Customer the contact / resolve dialogs are open for. */ current: KmClient | null = null; toastMsg = ''; toastVisible = false; private toastTimer: any; - /** Home Visit scheduling dialog */ - homeVisitOpen = false; - homeVisitOfficer = ''; - homeVisitDateTime = ''; - homeVisitNote = ''; - bankOfficers = [ - 'Amit Sharma — Branch Officer (Bhopal Main)', - 'Neha Verma — KYC Officer (Bhopal Main)', - 'Ravi Kulkarni — Field Officer (Bhopal Zone-2)', - 'Sunita Patel — Compliance Officer (Bhopal Main)', - 'Manoj Tiwari — Field Officer (Indore Zone-1)' - ]; - scheduledHomeVisits: { client: string; address: string; officer: string; dateTime: string; note: string }[] = []; - - /** Video KYC link dialog */ - vkycOpen = false; - vkycOfficer = ''; - vkycExpiry = ''; - vkycChannels = { sms: true, email: true, whatsapp: false }; - vkycMessage = ''; - vkycSessionId = ''; - sentVkycLinks: { - client: string; - mobile: string; - email: string; - officer: string; - expiry: string; - channels: string[]; - sessionId: string; - link: string; - }[] = []; - - clients: Record = { - rajesh: { - id: 'rajesh', - av: 'RS', - name: 'Rajesh Kumar Sharma', - meta: 'ACC-BPL-2024-00847 · Savings A/c · Bhopal Main', - account: 'ACC-BPL-2024-00847', - state: 'alert', - risk: 'low', - riskLabel: 'Low Risk', - expiry: '30 Apr 2027', - daysLeft: 15, - kyc: { - type: 'Individual', - initiated: '4 Apr 2026', - lastVerified: '4 Apr 2026', - validity: '10 Years', - pan: 'AQRPV8345E', - aadhaar: 'XXXXXXXX6039', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-99041', - email: 'r****@gmail.com', - address: 'House No. 42, Sector 5, Bhopal MP 462001', - branch: 'Bhopal Main' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '31 Mar 2027', - ref: 'MSG-2027-0331-001', - delivery: 'Delivered', - response: 'No Action' - }, - { - no: 'N2', - channel: 'Reg. Post + IVR', - date: '10 Apr 2027', - ref: 'RP-BPL-20270410-443', - delivery: 'In Transit', - response: 'Pending' - } - ], - noticesSent: 2, - noticePending: 'Notice 3', - nextStage: 'Debit Freeze', - pendingActions: [ - 'Send Notice 3 (due 20 Apr 2027)', - 'Schedule Home Visit', - 'Send Video KYC link' - ], - actionTaken: [ - 'Notice 1 delivered', - 'Notice 2 dispatched via Reg. Post' - ], - evidence: null - }, - priya: { - id: 'priya', - av: 'PM', - name: 'Priya Malhotra', - meta: 'ACC-BPL-2021-00231 · Savings A/c · Bhopal Main', - account: 'ACC-BPL-2021-00231', - state: 'freeze', - risk: 'mid', - riskLabel: 'Mid Risk', - expiry: '10 Apr 2027', - daysLeft: 0, - kyc: { - type: 'Individual', - initiated: '10 Apr 2019', - lastVerified: '10 Apr 2019', - validity: '8 Years', - pan: 'BKJPV1234A', - aadhaar: 'XXXXXXXX7721', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-55321', - email: 'p****@yahoo.com', - address: 'Flat 12B, Arera Colony, Bhopal MP 462016', - branch: 'Bhopal Main' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '10 Mar 2027', - ref: 'MSG-2027-0310-003', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N2', - channel: 'Reg. Post', - date: '20 Mar 2027', - ref: 'RP-BPL-20270320-091', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N3', - channel: 'Home Visit', - date: '30 Mar 2027', - ref: 'VIS-BPL-20270330-012', - delivery: 'Visit Done', - response: 'Refused' - } - ], - noticesSent: 3, - noticePending: 'None — all 3 sent', - nextStage: 'Inoperative by 30 Jun 2027', - pendingActions: [ - 'Account in Debit Freeze', - 'Inoperative transition due 30 Jun 2027', - 'Maker-checker unfreeze pending' - ], - actionTaken: [ - 'Notice 1 delivered', - 'Notice 2 delivered via Reg. Post', - 'Notice 3 — Branch visit done, client refused', - 'Debit Freeze applied 10 Apr 2027' - ], - evidence: { - type: 'visit', - date: '30 Mar 2027', - officer: 'Anupam Mishra', - gps: '23.2599° N, 77.4126° E', - location: 'Arera Colony, Bhopal', - outcome: 'Client refused to sign self-declaration form. Informed about debit freeze implications.', - docket: 'VIS-BPL-20270330-012' - } - }, - suresh: { - id: 'suresh', - av: 'SP', - name: 'Suresh Patel', - meta: 'ACC-BPL-2019-00112 · Savings A/c · Bhopal South', - account: 'ACC-BPL-2019-00112', - state: 'inop', - risk: 'high', - riskLabel: 'High Risk', - expiry: '15 Mar 2027', - daysLeft: 0, - kyc: { - type: 'Individual', - initiated: '15 Mar 2025', - lastVerified: '15 Mar 2025', - validity: '2 Years', - pan: 'CXRTS5678B', - aadhaar: 'XXXXXXXX2211', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-88765', - email: 's****@gmail.com', - address: 'Village Ratibad, Bhopal MP 462044', - branch: 'Bhopal South' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '13 Feb 2027', - ref: 'MSG-2027-0213-006', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N2', - channel: 'Reg. Post + IVR', - date: '23 Feb 2027', - ref: 'RP-BPL-20270223-044', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N3', - channel: 'Video KYC', - date: '5 Mar 2027', - ref: 'VKYC-2027-0305-007', - delivery: 'No Response', - response: 'No Action' - } - ], - noticesSent: 3, - noticePending: 'None', - nextStage: 'Dormant by 30 Sep 2027', - pendingActions: [ - 'Account Inoperative — all txns blocked', - 'Dormant transition due 30 Sep 2027', - 'RBI Dormant Account reporting' - ], - actionTaken: [ - 'Notice 1 delivered', - 'Notice 2 delivered via Reg. Post', - 'Notice 3 — Video KYC invite sent (no response)', - 'Debit Freeze 15 Mar 2027', - 'Inoperative 15 May 2027' - ], - evidence: { - type: 'vkyc', - date: '5 Mar 2027', - officer: 'System', - ref: 'VKYC-2027-0305-007', - outcome: 'Video KYC session link sent. Client did not join within 48 hours. Session auto-expired.' - } - }, - kavitha: { - id: 'kavitha', - av: 'KN', - name: 'Kavitha Nair', - meta: 'ACC-BPL-2018-00088 · Savings A/c · Bhopal East', - account: 'ACC-BPL-2018-00088', - state: 'dormant', - risk: 'mid', - riskLabel: 'Mid Risk', - expiry: '1 Jan 2027', - daysLeft: 0, - kyc: { - type: 'Individual', - initiated: '1 Jan 2019', - lastVerified: '1 Jan 2019', - validity: '8 Years', - pan: 'DVJNK9012C', - aadhaar: 'XXXXXXXX4492', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-11092', - email: 'k****@rediff.com', - address: 'Plot 7, Awadhpuri, Bhopal MP 462010', - branch: 'Bhopal East' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '1 Dec 2026', - ref: 'MSG-2026-1201-009', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N2', - channel: 'Reg. Post', - date: '11 Dec 2026', - ref: 'RP-BPL-20261211-077', - delivery: 'Delivered', - response: 'No Response' - }, - { - no: 'N3', - channel: 'Home Visit', - date: '22 Dec 2026', - ref: 'VIS-BPL-20261222-019', - delivery: 'Not Found', - response: 'Not Found at Address' - } - ], - noticesSent: 3, - noticePending: 'None', - nextStage: 'Dormant (Final) — RBI Reporting', - pendingActions: [ - 'RBI Dormant Account Report submission', - 'Branch Manager approval for any reversal', - 'Regional Manager sign-off required' - ], - actionTaken: [ - 'Notice 1–3 sent, all no response', - 'Debit Freeze 1 Jan 2027', - 'Inoperative 2 Mar 2027', - 'Dormant 1 Jun 2027', - 'RBI report auto-generated' - ], - evidence: { - type: 'visit', - date: '22 Dec 2026', - officer: 'Rekha Sharma', - gps: '23.2491° N, 77.4384° E', - location: 'Awadhpuri, Bhopal', - outcome: - 'Client not found at registered address. Neighbours confirmed relocation. Address verification required.', - docket: 'VIS-BPL-20261222-019' - } - }, - amit: { - id: 'amit', - av: 'AS', - name: 'Amit Singh', - meta: 'ACC-BPL-2024-01103 · Savings A/c · Bhopal Main', - account: 'ACC-BPL-2024-01103', - state: 'alert', - risk: 'low', - riskLabel: 'Low Risk', - expiry: '5 May 2027', - daysLeft: 20, - kyc: { - type: 'Individual', - initiated: '5 May 2016', - lastVerified: '5 May 2016', - validity: '10 Years', - pan: 'EKJPS4321D', - aadhaar: 'XXXXXXXX8834', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-22334', - email: 'a****@gmail.com', - address: 'C-22, Shivaji Nagar, Bhopal MP 462016', - branch: 'Bhopal Main' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '5 Apr 2027', - ref: 'MSG-2027-0405-011', - delivery: 'Delivered', - response: 'No Action' - } - ], - noticesSent: 1, - noticePending: 'Notice 2 & 3', - nextStage: 'Debit Freeze if no action', - pendingActions: [ - 'Send Notice 2 (due 15 Apr 2027)', - 'Send Notice 3 (due 25 Apr 2027)', - 'Branch visit if needed' - ], - actionTaken: ['Notice 1 delivered 5 Apr 2027'], - evidence: null - }, - meena: { - id: 'meena', - av: 'MJ', - name: 'Meena Joshi', - meta: 'ACC-BPL-2022-00567 · Savings A/c · Bhopal North', - account: 'ACC-BPL-2022-00567', - state: 'urgent', - risk: 'high', - riskLabel: 'High Risk', - expiry: '28 Apr 2027', - daysLeft: 3, - kyc: { - type: 'Individual', - initiated: '28 Apr 2025', - lastVerified: '28 Apr 2025', - validity: '2 Years', - pan: 'FJLMN6789E', - aadhaar: 'XXXXXXXX3309', - panStatus: 'Verified', - aadhaarStatus: 'Verified', - panLink: 'Linked' - }, - contact: { - mobile: '+91-XXXXX-77654', - email: 'm****@gmail.com', - address: 'Lane 4, Shyamla Hills, Bhopal MP 462002', - branch: 'Bhopal North' - }, - notices: [ - { - no: 'N1', - channel: 'SMS + Email', - date: '29 Mar 2027', - ref: 'MSG-2027-0329-014', - delivery: 'Delivered', - response: 'No Action' - }, - { - no: 'N2', - channel: 'Reg. Post + IVR', - date: '8 Apr 2027', - ref: 'RP-BPL-20270408-088', - delivery: 'Delivered', - response: 'No Response' - } - ], - noticesSent: 2, - noticePending: 'URGENT: Notice 3', - nextStage: 'Debit Freeze in 3 days', - pendingActions: [ - 'URGENT: Send Notice 3 NOW', - 'Schedule Home Visit immediately', - 'Consider Video KYC as last resort' - ], - actionTaken: [ - 'Notice 1 delivered', - 'Notice 2 delivered via Reg. Post + IVR' - ], - evidence: null - } - }; - - alertRows: KmClient[] = []; exceptionRows: KmClient[] = []; pendingExceptions: KmClient[] = []; resolvedExceptions: KmClient[] = []; @@ -572,7 +161,10 @@ export class KycManagementComponent implements OnInit { contactDialogOpen = false; contactChannel = 'PHONE_CALL'; contactNotes = ''; - contactOfficer = 'Neha Verma — KYC Officer (Bhopal Main)'; + contactOfficer = ''; + /** Set when the dialog was opened from the dashboard: the stage-specific notice the customer gets. */ + contactNoticeType = ''; + contactPreview: { subject: string; body: string } | null = null; /** In-flight guard — a double-click must not send the customer two notices. */ contactSaving = false; /** Populated when the save itself failed (not when the save worked but delivery didn't). */ @@ -609,9 +201,1288 @@ export class KycManagementComponent implements OnInit { showAadhaarResolution = false; showGeneralResolution = false; + // ── Live status data (Account Status / Notification Scheduler / Audit Inventory) ────────────── + + statusLoading = false; + statusError = ''; + expiryRows: KycExpiryRow[] = []; + /** Re-KYC due within the next 30 days — not lapsed yet, so kept apart from {@link expiryRows}. */ + expiringRows: KycExpiryRow[] = []; + dormancyRows: DormancyRow[] = []; + expiryTotals: KycExpirySummary = expirySummary([]); + dormancyTotals: DormancySummary = dormancySummary([], businessTodayIso()); + readonly expiryPolicy = KYC_EXPIRY_POLICY; + readonly dormancyPolicy = DORMANCY_POLICY; + readonly alwaysAllowed = ALWAYS_ALLOWED; + + /** Every outreach attempt on record across the lapsed customers, newest first. */ + get auditRows(): { row: KycExpiryRow; log: KycContactRow }[] { + return this.expiryRows + .flatMap((row) => row.contacts.map((log) => ({ row, log }))) + .sort((a, b) => (b.log.date || '').localeCompare(a.log.date || '')); + } + + /** Reads the same sources as the Expired KYC and Dormancy reports, so the figures cannot differ. */ + /** Card picked on the Alert Dashboard; its accounts are listed beneath the cards. */ + alertFilter: 'EXPIRING' | 'GRACE' | 'PARTIAL_FREEZE' | 'INOPERATIVE' | 'INACTIVE' | 'DORMANT' | 'ESCHEAT' | null = + null; + + readonly alertLabels: Record = { + EXPIRING: 'Expiring in 30 days', + GRACE: 'Overdue — Grace Period', + PARTIAL_FREEZE: 'Debit Freeze', + INOPERATIVE: 'Inoperative', + INACTIVE: 'Inactive', + DORMANT: 'Dormant', + ESCHEAT: 'Escheat (DEAF)' + }; + + selectAlert(key: NonNullable): void { + this.alertFilter = this.alertFilter === key ? null : key; + } + + /** The two full reports sit at the foot of the dashboard and load only when opened. */ + showExpiryReport = false; + showDormancyReport = false; + + /** Filters above the cards; they narrow the list of accounts beneath them. */ + alertSearch = ''; + alertRisk = 0; + alertContact: '' | 'NONE' | 'CONTACTED' | 'ACK' = ''; + + get alertFiltersActive(): boolean { + return !!(this.alertFilter || this.alertSearch.trim() || this.alertRisk || this.alertContact); + } + + resetAlertFilters(): void { + this.alertFilter = null; + this.alertSearch = ''; + this.alertRisk = 0; + this.alertContact = ''; + } + + /** Accounts matching the chosen card and filters — same sources as the card counts. */ + get alertList(): { + clientId: number; + stageKey: string; + mobile: string; + email?: string; + name: string; + account: string; + branch: string; + stage: string; + since: string; + days: number; + daysLabel: string; + next: string; + nextDate: string; + balance: number | null; + }[] { + if (!this.alertFiltersActive) { + return []; + } + const needle = this.alertSearch.trim().toLowerCase(); + const matches = (name: string, account: string) => + !needle || name.toLowerCase().includes(needle) || (account || '').toLowerCase().includes(needle); + const wanted = (stage: string) => !this.alertFilter || this.alertFilter === stage; + const keyOf = (r: KycExpiryRow) => (r.stage === 'DUE_SOON' ? 'EXPIRING' : r.stage); + + // Risk and outreach are properties of the KYC record; a dormancy row has neither, so it drops + // out whenever one of those filters is set rather than being shown as if it matched. + const kycOnly = !!(this.alertRisk || this.alertContact); + + const kyc = [ + ...this.expiringRows, + ...this.expiryRows + ] + .filter((r) => wanted(keyOf(r)) && matches(r.clientName, r.accountNo)) + .filter((r) => !this.alertRisk || r.riskCategory === this.alertRisk) + .filter( + (r) => + !this.alertContact || + (this.alertContact === 'NONE' && r.contactCount === 0) || + (this.alertContact === 'CONTACTED' && r.contactCount > 0) || + (this.alertContact === 'ACK' && r.acknowledged) + ) + .map((r) => ({ + clientId: r.clientId, + stageKey: keyOf(r), + mobile: r.mobile, + email: r.email as string | undefined, + name: r.clientName, + account: r.accountNo, + branch: r.branchCode, + stage: this.alertLabels[keyOf(r)] || r.meta.label, + since: r.expiryDate, + days: Math.abs(r.daysOverdue), + daysLabel: r.daysOverdue < 0 ? 'days left' : 'days overdue', + next: r.nextActionLabel, + nextDate: r.nextActionDate, + balance: null as number | null + })); + + const dormant = kycOnly + ? [] + : this.dormancyRows + .filter((r) => wanted(r.stage) && matches(r.clientName, r.accountNo)) + .map((r) => ({ + clientId: r.clientId, + stageKey: r.stage as string, + mobile: r.mobile, + email: undefined as string | undefined, + name: r.clientName, + account: r.accountNo, + branch: r.branchCode, + stage: this.alertLabels[r.stage] || r.stage, + since: r.lastCustomerTxnDate, + days: r.daysIdle, + daysLabel: 'days idle', + next: r.nextStageLabel || '—', + nextDate: r.nextStageDate, + balance: r.balance as number | null + })); + + return [ + ...kyc, + ...dormant + ]; + } + + /** Which family of notice a stage gets: a re-KYC notice, or one about an account that has gone quiet. */ + noticeTypeFor(stageKey?: string): string { + return [ + 'INACTIVE', + 'DORMANT', + 'ESCHEAT' + ].includes(stageKey || '') ? 'DORMANCY' : stageKey ? 'KYC' : ''; + } + + /** Notify: records and sends a notice through the same contact-log API the KYC screens use. */ + openAlertNotify(r: { + clientId: number; + name: string; + account: string; + branch: string; + mobile: string; + email?: string; + stageKey?: string; + }): void { + const av = r.name + .split(' ') + .map((n) => n[0]) + .join('') + .substring(0, 2) + .toUpperCase(); + const stub: KmClient = { + id: String(r.clientId), + av, + name: r.name, + meta: r.account, + state: 'alert', + risk: 'low', + riskLabel: '', + expiry: '', + daysLeft: 0, + account: r.account, + kyc: { + type: '', + initiated: '', + lastVerified: '', + validity: '', + pan: '', + aadhaar: '', + panStatus: '', + aadhaarStatus: '', + panLink: '' + }, + contact: { mobile: r.mobile || '—', email: r.email || '—', address: '', branch: r.branch }, + notices: [], + noticesSent: 0, + noticePending: '', + nextStage: '', + pendingActions: [], + actionTaken: [], + evidence: null, + hasMobile: !!r.mobile, + // The dormancy list carries no email; leave it unknown rather than claiming there is none. + hasEmail: r.email === undefined ? undefined : !!r.email, + contactHistory: [] + }; + this.openContactDialog(stub); + this.contactNoticeType = this.noticeTypeFor(r.stageKey); + this.loadNoticePreview(r.clientId); + } + + /** What the customer would receive, shown before the officer sends it. */ + loadNoticePreview(clientId: number): void { + this.contactPreview = null; + if (!this.contactNoticeType) { + return; + } + this.clientsService.previewKycNotice(clientId, this.contactNoticeType).subscribe({ + next: (n) => (this.contactPreview = n?.body ? { subject: n.subject || '', body: n.body } : null), + error: () => (this.contactPreview = null) + }); + } + + /** + * Activating a restricted account means renewing the customer's KYC: the server lifts the KYC + * block and revives inactive / dormant savings accounts when the re-KYC is completed, and does + * nothing for a plain transaction. So the KYC screen is where this happens. + */ + openAlertKyc(clientId: number): void { + this.router.navigate([ + '/clients', + clientId, + 'kyc' + ]); + } + + openAlertClient(clientId: number): void { + this.router.navigate([ + '/clients', + clientId, + 'general' + ]); + } + + // ── Bulk notify ──────────────────────────────────────────────────────────── + + /** Customers ticked in the list, by client id — a notice goes to the customer, not to an account. */ + alertSelected = new Set(); + bulkOpen = false; + bulkChannel = 'SMS'; + bulkNotes = ''; + bulkOfficer = ''; + bulkSending = false; + bulkDone = false; + /** When to send; blank means as soon as the queue job next runs (within about a minute). */ + bulkSendAt = ''; + bulkError = ''; + bulkQueued: { queued: number; skipped: number } | null = null; + bulkResults: { name: string; account: string; ok: boolean; message: string }[] = []; + /** Kept modest so one click cannot flood the SMS gateway. */ + readonly bulkLimit = 50; + /** Only the channels that actually transmit; record-only channels need notes written per customer. */ + readonly bulkChannels = [ + { value: 'SMS', label: 'SMS' }, + { value: 'EMAIL', label: 'Email' }, + { value: 'WHATSAPP', label: 'WhatsApp Message' } + ]; + + toggleAlertRow(clientId: number): void { + if (this.alertSelected.has(clientId)) { + this.alertSelected.delete(clientId); + } else { + this.alertSelected.add(clientId); + } + } + + get alertAllSelected(): boolean { + const rows = this.alertList; + return rows.length > 0 && rows.every((r) => this.alertSelected.has(r.clientId)); + } + + toggleAlertAll(): void { + const rows = this.alertList; + if (this.alertAllSelected) { + rows.forEach((r) => this.alertSelected.delete(r.clientId)); + } else { + rows.forEach((r) => this.alertSelected.add(r.clientId)); + } + } + + /** One entry per customer: two accounts of the same customer must not get two notices. */ + get bulkTargets(): { + clientId: number; + name: string; + account: string; + mobile: string; + email?: string; + noticeType: string; + }[] { + const seen = new Set(); + const targets: { + clientId: number; + name: string; + account: string; + mobile: string; + email?: string; + noticeType: string; + }[] = []; + for (const r of this.alertList) { + if (this.alertSelected.has(r.clientId) && !seen.has(r.clientId)) { + seen.add(r.clientId); + targets.push({ + clientId: r.clientId, + name: r.name, + account: r.account, + mobile: r.mobile, + email: r.email, + noticeType: this.noticeTypeFor(r.stageKey) + }); + } + } + return targets; + } + + /** How many of the selected customers get each kind of message, so the officer sees what is going out. */ + get bulkNoticeSummary(): { label: string; count: number }[] { + const kyc = this.bulkTargets.filter((t) => t.noticeType === 'KYC').length; + const dormancy = this.bulkTargets.filter((t) => t.noticeType === 'DORMANCY').length; + return [ + { label: 'Re-KYC notice (expiry, freeze or inoperative dates)', count: kyc }, + { label: 'Account notice (inactive, dormant or closed)', count: dormancy } + ].filter((x) => x.count > 0); + } + + get bulkSendDisabled(): boolean { + return ( + this.bulkSending || + !this.bulkNotes.trim() || + this.bulkTargets.length === 0 || + this.bulkTargets.length > this.bulkLimit + ); + } + + openBulk(): void { + this.bulkChannel = 'SMS'; + this.bulkNotes = ''; + this.bulkOfficer = ''; + this.bulkResults = []; + this.bulkDone = false; + this.bulkSendAt = ''; + this.bulkError = ''; + this.bulkQueued = null; + this.bulkOpen = true; + } + + closeBulk(): void { + if (this.bulkSending) { + return; + } + this.bulkOpen = false; + } + + /** Hands the selected customers to the queue; the queue job sends them in the background. */ + sendBulk(): void { + if (this.bulkSendDisabled) { + return; + } + this.bulkSending = true; + this.clientsService + .enqueueKycNotices({ + items: this.bulkTargets.map((t) => ({ + clientId: t.clientId, + noticeType: t.noticeType, + channel: this.bulkChannel, + accountNo: t.account, + stageLabel: t.noticeType === 'DORMANCY' ? 'Account notice' : 'Re-KYC notice' + })), + notes: this.bulkNotes.trim(), + officerName: this.bulkOfficer.trim() || undefined, + sendAt: this.bulkSendAt || undefined + }) + .subscribe({ + next: (r) => { + this.bulkSending = false; + this.bulkDone = true; + this.bulkQueued = r; + this.alertSelected.clear(); + this.loadQueue(); + }, + error: (err) => { + this.bulkSending = false; + this.bulkError = + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + 'Could not queue the notices.'; + } + }); + } + + // ── Notification scheduler ───────────────────────────────────────────────── + + /** + * The escalation ladder an officer works down for each customer. The first three transmit; the rest + * are things the branch does and records, so they are listed but never sent from here. + */ + readonly schedulerLadder: { key: string; label: string; sends: boolean; notice?: number }[] = [ + { key: 'SMS', label: 'SMS', sends: true, notice: 1 }, + { key: 'EMAIL', label: 'Email', sends: true, notice: 2 }, + { key: 'WHATSAPP', label: 'WhatsApp', sends: true, notice: 3 }, + { key: 'PHONE_CALL', label: 'Phone Call', sends: false }, + { key: 'BRANCH_VISIT', label: 'Branch Visit', sends: false }, + { key: 'FIELD_VISIT', label: 'Field Visit', sends: false } + ]; + + schedScope: 'ALL' | 'EXPIRING' | 'LAPSED' = 'ALL'; + schedSearch = ''; + schedSelected = new Set(); + schedOpen = false; + schedOfficer = ''; + schedNote = ''; + schedSending = false; + schedDone = false; + schedError = ''; + schedQueued: { queued: number; skipped: number } | null = null; + schedResults: { name: string; account: string; step: string; ok: boolean; message: string }[] = []; + + /** One line per customer with the ladder worked out from the outreach already on record. */ + get schedulerRows(): { + row: KycExpiryRow; + steps: { label: string; state: 'done' | 'next' | 'todo' }[]; + next: { key: string; label: string; sends: boolean; notice?: number } | null; + }[] { + const needle = this.schedSearch.trim().toLowerCase(); + const source = + this.schedScope === 'EXPIRING' ? this.expiringRows : this.schedScope === 'LAPSED' ? this.expiryRows : [ + ...this.expiryRows, + ...this.expiringRows + ]; + return source + .filter( + (r) => !needle || r.clientName.toLowerCase().includes(needle) || r.accountNo.toLowerCase().includes(needle) + ) + .map((row) => { + // A send that failed does not count: the customer was not reached, so the same rung is due again. + const done = (s: { key: string; sends: boolean }) => + row.contacts.some((c) => c.channel === s.key && (c.delivered || !s.sends)); + let nextFound = false; + let next: { key: string; label: string; sends: boolean; notice?: number } | null = null; + const steps = this.schedulerLadder.map((s) => { + if (done(s)) { + return { label: s.label, state: 'done' as const }; + } + if (!nextFound) { + nextFound = true; + next = s; + return { label: s.label, state: 'next' as const }; + } + return { label: s.label, state: 'todo' as const }; + }); + return { row, steps, next }; + }); + } + + /** Customers whose next rung is a notice the system can send now. */ + get schedulerDueCount(): number { + return this.schedulerRows.filter((r) => r.next?.sends).length; + } + + get schedulerNeverContacted(): number { + return this.schedulerRows.filter((r) => r.row.contactCount === 0).length; + } + + toggleSched(clientId: number): void { + if (this.schedSelected.has(clientId)) { + this.schedSelected.delete(clientId); + } else { + this.schedSelected.add(clientId); + } + } + + get schedAllSelected(): boolean { + const rows = this.schedulerRows.filter((r) => r.next?.sends); + return rows.length > 0 && rows.every((r) => this.schedSelected.has(r.row.clientId)); + } + + toggleSchedAll(): void { + const rows = this.schedulerRows.filter((r) => r.next?.sends); + if (this.schedAllSelected) { + rows.forEach((r) => this.schedSelected.delete(r.row.clientId)); + } else { + rows.forEach((r) => this.schedSelected.add(r.row.clientId)); + } + } + + /** The ticked customers, each with the notice that is due for them. */ + get schedTargets(): KycManagementComponent['schedulerRows'] { + return this.schedulerRows.filter((r) => r.next?.sends && this.schedSelected.has(r.row.clientId)); + } + + get schedSendDisabled(): boolean { + return this.schedSending || this.schedTargets.length === 0 || this.schedTargets.length > this.bulkLimit; + } + + openSched(): void { + this.schedOfficer = ''; + this.schedNote = ''; + this.schedResults = []; + this.schedDone = false; + this.schedError = ''; + this.schedQueued = null; + this.schedOpen = true; + } + + /** Row-level shortcut: dispatch just this customer's next notice. */ + dispatchOne(clientId: number): void { + this.schedSelected = new Set([clientId]); + this.openSched(); + } + + closeSched(): void { + if (!this.schedSending) { + this.schedOpen = false; + } + } + + /** Queues each selected customer's next notice; the queue job sends them in the background. */ + dispatchScheduled(): void { + if (this.schedSendDisabled) { + return; + } + this.schedSending = true; + this.clientsService + .enqueueKycNotices({ + items: this.schedTargets.map((t) => ({ + clientId: t.row.clientId, + noticeType: 'KYC', + channel: t.next!.key, + accountNo: t.row.accountNo, + stageLabel: 'Re-KYC notice ' + (t.next!.notice ?? '') + ' (' + t.next!.label + ')' + })), + notes: this.schedNote.trim() || 'Re-KYC reminder — scheduled notice', + officerName: this.schedOfficer.trim() || undefined + }) + .subscribe({ + next: (r) => { + this.schedSending = false; + this.schedDone = true; + this.schedQueued = r; + this.schedSelected.clear(); + this.loadQueue(); + }, + error: (err) => { + this.schedSending = false; + this.schedError = + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + 'Could not queue the notices.'; + } + }); + } + + // ── Audit inventory: every notice on record, a day at a time ─────────────── + + /** One line per day, worked out by the server so a very large log is never loaded whole. */ + auditDays: { + date: string; + notices: number; + customers: number; + delivered: number; + failed: number; + noContact: number; + notConfigured: number; + recorded: number; + }[] = []; + auditFrom = ''; + auditTo = ''; + auditChannel = ''; + auditStatus = ''; + auditSearch = ''; + auditLoading = false; + auditError = ''; + + /** The day open in the popup, and the page of its notices on screen. */ + auditDay: string | null = null; + auditDayRows: { + id: number; + clientId: number; + clientName: string; + accountNo: string; + channel: string; + notes: string; + officerName: string; + deliveryStatus: string; + destinationMasked: string; + provider: string; + providerReference: string; + failureReason: string; + messageBody: string; + contactedAt: string; + }[] = []; + auditDayTotal = 0; + auditDayPage = 0; + readonly auditPageSize = 50; + auditDayLoading = false; + auditDayError = ''; + auditExpanded: number | null = null; + + readonly auditChannelLabels: Record = { + SMS: 'SMS', + EMAIL: 'Email', + WHATSAPP: 'WhatsApp', + PHONE_CALL: 'Phone Call', + BRANCH_VISIT: 'Branch Visit', + FIELD_VISIT: 'Field Visit' + }; + readonly auditStatusLabels: Record = { + SENT: 'Delivered', + FAILED: 'Failed', + SKIPPED_NO_DESTINATION: 'No contact detail', + PROVIDER_NOT_CONFIGURED: 'Gateway not set up', + NOT_APPLICABLE: 'Recorded' + }; + + get auditNoticeTotal(): number { + return this.auditDays.reduce((n, d) => n + d.notices, 0); + } + + private auditFilter(): { from?: string; to?: string; channel?: string; status?: string; search?: string } { + return { + from: this.auditFrom, + to: this.auditTo, + channel: this.auditChannel, + status: this.auditStatus, + search: this.auditSearch.trim() + }; + } + + loadAudit(): void { + this.auditLoading = true; + this.auditError = ''; + this.clientsService.getKycContactAuditDays(this.auditFilter()).subscribe({ + next: (days) => { + this.auditDays = days || []; + this.auditLoading = false; + }, + error: () => { + this.auditLoading = false; + this.auditError = 'Could not load the notice log.'; + } + }); + } + + resetAudit(): void { + this.auditFrom = ''; + this.auditTo = ''; + this.auditChannel = ''; + this.auditStatus = ''; + this.auditSearch = ''; + this.loadAudit(); + } + + openAuditDay(date: string): void { + this.auditDay = date; + this.auditDayPage = 0; + this.auditExpanded = null; + this.loadAuditDay(); + } + + closeAuditDay(): void { + this.auditDay = null; + this.auditDayRows = []; + } + + /** The popup applies the same channel, status and search as the day list, narrowed to its one day. */ + loadAuditDay(): void { + if (!this.auditDay) { + return; + } + this.auditDayLoading = true; + this.auditDayError = ''; + this.clientsService + .getKycContactAudit({ + ...this.auditFilter(), + from: this.auditDay, + to: this.auditDay, + offset: this.auditDayPage * this.auditPageSize, + limit: this.auditPageSize + }) + .subscribe({ + next: (r) => { + this.auditDayRows = r?.rows || []; + this.auditDayTotal = r?.total || 0; + this.auditDayLoading = false; + }, + error: () => { + this.auditDayLoading = false; + this.auditDayError = 'Could not load the notices for this day.'; + } + }); + } + + auditDayPageCount(): number { + return Math.max(1, Math.ceil(this.auditDayTotal / this.auditPageSize)); + } + + moveAuditDayPage(by: number): void { + this.auditDayPage = Math.min(Math.max(this.auditDayPage + by, 0), this.auditDayPageCount() - 1); + this.auditExpanded = null; + this.loadAuditDay(); + } + + auditStatusClass(status: string): string { + return status === 'SENT' + ? 'km-b-gr' + : status === 'NOT_APPLICABLE' + ? 'km-b-gy' + : status === 'FAILED' + ? 'km-b-re' + : 'km-b-am'; + } + + /** Fetches notices a page at a time for a workbook, up to a cap so one click cannot pull a whole year. */ + private async fetchAuditRows( + filter: { from?: string; to?: string; channel?: string; status?: string; search?: string }, + cap: number + ): Promise<{ rows: KycManagementComponent['auditDayRows']; total: number }> { + const rows: KycManagementComponent['auditDayRows'] = []; + let total = 0; + while (rows.length < cap) { + const page = await firstValueFrom( + this.clientsService.getKycContactAudit({ + ...filter, + offset: rows.length, + limit: Math.min(500, cap - rows.length) + }) + ); + total = page?.total || 0; + const batch = page?.rows || []; + rows.push(...batch); + if (!batch.length || rows.length >= total) { + break; + } + } + return { rows, total }; + } + + private readonly auditHeaders = [ + '#', + 'Date & time', + 'Customer', + 'Account', + 'Channel', + 'Delivery', + 'Sent to', + 'Reference', + 'Officer', + 'Note', + 'Failure reason', + 'Message sent' + ]; + private readonly auditWidths = [ + 6, + 20, + 28, + 20, + 14, + 18, + 22, + 22, + 22, + 40, + 30, + 80 + ]; + private readonly auditDayHeaders = [ + 'Date', + 'Customers', + 'Notices', + 'Delivered', + 'Failed', + 'No contact detail', + 'Gateway not set up', + 'Recorded (call / visit)' + ]; + + private auditNoticeSheetRows(rows: KycManagementComponent['auditDayRows']): (string | number | null)[][] { + return rows.map((r, i) => [ + i + 1, + (r.contactedAt || '').replace('T', ' ').slice(0, 19), + r.clientName, + r.accountNo, + this.auditChannelLabels[r.channel] || r.channel, + this.auditStatusLabels[r.deliveryStatus] || r.deliveryStatus, + r.destinationMasked || '', + r.providerReference || '', + r.officerName || '', + r.notes || '', + r.failureReason || '', + r.messageBody || '' + ]); + } + + private auditDaySheetRows(): (string | number | null)[][] { + return this.auditDays.map((d) => [ + d.date, + d.customers, + d.notices, + d.delivered, + d.failed, + d.noContact, + d.notConfigured, + d.recorded + ]); + } + + /** The day list as a workbook: one line per day with its counts. */ + async exportAudit(): Promise { + if (!this.auditDays.length) { + this.toast('There are no notices to export.'); + return; + } + try { + await exportWorkbook( + [ + { + sheetName: 'Notices per day', + title: 'KYC & account notices sent to customers — per day', + subtitle: this.auditNoticeTotal + ' notice(s)', + headers: this.auditDayHeaders, + rows: this.auditDaySheetRows(), + widths: [ + 14, + 12, + 12, + 12, + 12, + 20, + 20, + 24 + ] + } + ], + 'KYC-Notices-Per-Day-' + businessTodayIso() + ); + this.toast('Downloaded.'); + } catch { + this.toast('Could not build the file. Please try again.'); + } + } + + /** Every notice of the day open in the popup, with the exact text sent. */ + async exportAuditDay(): Promise { + if (!this.auditDay) { + return; + } + try { + const { rows, total } = await this.fetchAuditRows( + { ...this.auditFilter(), from: this.auditDay, to: this.auditDay }, + 5000 + ); + await exportWorkbook( + [ + { + sheetName: 'Notice log', + title: 'Notices sent on ' + this.auditDay, + subtitle: rows.length + (rows.length < total ? ' of ' + total : '') + ' notice(s)', + headers: this.auditHeaders, + rows: this.auditNoticeSheetRows(rows), + widths: this.auditWidths + } + ], + 'KYC-Notices-' + this.auditDay + ); + this.toast( + rows.length < total ? 'Downloaded the first ' + rows.length + ' of ' + total + ' notices.' : 'Downloaded.' + ); + } catch { + this.toast('Could not build the file. Please try again.'); + } + } + + /** The workbook handed to an inspector: a summary, the per-day figures, then the notices with the exact text sent. */ + async exportRbiReport(): Promise { + if (!this.auditDays.length) { + this.toast('There are no notices to report.'); + return; + } + const sum = (pick: (d: KycManagementComponent['auditDays'][number]) => number) => + this.auditDays.reduce((n, d) => n + pick(d), 0); + const period = + (this.auditFrom || this.auditDays[this.auditDays.length - 1].date) + + ' to ' + + (this.auditTo || this.auditDays[0].date); + const summary: (string | number | null)[][] = [ + [ + 'Notices on record', + sum((d) => d.notices) + ], + [ + 'Delivered', + sum((d) => d.delivered) + ], + [ + 'Failed', + sum((d) => d.failed) + ], + [ + 'No contact detail on file', + sum((d) => d.noContact) + ], + [ + 'Gateway not set up', + sum((d) => d.notConfigured) + ], + [ + 'Recorded (call / visit)', + sum((d) => d.recorded) + ], + [ + 'Days with notices', + this.auditDays.length + ] + ]; + try { + const { rows, total } = await this.fetchAuditRows(this.auditFilter(), 2000); + await exportWorkbook( + [ + { sheetName: 'Summary', title: 'Customer notice audit — summary', subtitle: 'Period: ' + period, headers: [ + 'Measure', + 'Count' + ], rows: summary, widths: [ + 40, + 14 + ] }, + { + sheetName: 'Per day', + title: 'Customer notice audit — per day', + subtitle: 'Period: ' + period, + headers: this.auditDayHeaders, + rows: this.auditDaySheetRows(), + widths: [ + 14, + 12, + 12, + 12, + 12, + 20, + 20, + 24 + ] + }, + { + sheetName: 'Notice log', + title: 'Customer notice audit — notices', + subtitle: + 'Period: ' + period + (rows.length < total ? ' · newest ' + rows.length + ' of ' + total + ' shown' : ''), + headers: this.auditHeaders, + rows: this.auditNoticeSheetRows(rows), + widths: this.auditWidths + } + ], + 'RBI-KYC-Notice-Audit-' + businessTodayIso() + ); + this.toast('RBI audit report downloaded.'); + } catch { + this.toast('Could not build the report. Please try again.'); + } + } + + /** Alert Dashboard: the list on screen, as a workbook. */ + async exportAlerts(): Promise { + const rows = this.alertList; + if (!rows.length) { + this.toast('Pick a card or a filter first — there is no list to export.'); + return; + } + try { + await exportWorkbook( + [ + { + sheetName: 'Accounts', + title: this.alertFilter ? this.alertLabels[this.alertFilter] : 'Accounts needing attention', + subtitle: rows.length + ' account(s) as on ' + businessTodayIso(), + headers: [ + 'Customer', + 'Account', + 'Branch', + 'State', + 'Date', + 'Days', + 'Balance', + 'Next stage', + 'Next date' + ], + rows: rows.map((r) => [ + r.name, + r.account, + r.branch, + r.stage, + r.since, + r.days + ' ' + r.daysLabel, + r.balance, + r.next, + r.nextDate || '' + ]), + widths: [ + 28, + 22, + 18, + 24, + 14, + 20, + 16, + 30, + 14 + ] + } + ], + 'KYC-Alerts-' + businessTodayIso() + ); + this.toast('Downloaded.'); + } catch { + this.toast('Could not build the file. Please try again.'); + } + } + + // ── Notice queue (what officers submitted, and how it went) ─────────────── + + queueRows: { + id: number; + clientId: number; + clientName: string; + accountNo: string; + noticeType: string; + channel: string; + stageLabel: string; + status: string; + attempts: number; + resultMessage: string; + scheduledFor: string; + createdAt: string; + processedAt: string; + }[] = []; + queueFilter = ''; + queueLoading = false; + queueError = ''; + private queueTimer: any; + + get queueCounts(): Record { + const counts: Record = { PENDING: 0, SENDING: 0, SENT: 0, FAILED: 0, CANCELLED: 0 }; + this.queueRows.forEach((r) => (counts[r.status] = (counts[r.status] || 0) + 1)); + return counts; + } + + get queueView(): KycManagementComponent['queueRows'] { + return this.queueFilter ? this.queueRows.filter((r) => r.status === this.queueFilter) : this.queueRows; + } + + /** The queue grouped by the day it was submitted, newest day first, with a count per outcome. */ + get queueGroups(): { + date: string; + rows: KycManagementComponent['queueRows']; + customers: number; + waiting: number; + sent: number; + failed: number; + cancelled: number; + }[] { + const byDate = new Map(); + for (const r of this.queueView) { + const day = (r.createdAt || '').slice(0, 10); + byDate.set(day, [ + ...(byDate.get(day) || []), + r + ]); + } + return [...byDate.entries()] + .sort((a, b) => b[0].localeCompare(a[0])) + .map( + ([ + date, + rows + ]) => ({ + date, + rows, + customers: new Set(rows.map((r) => r.clientId)).size, + waiting: rows.filter((r) => r.status === 'PENDING' || r.status === 'SENDING').length, + sent: rows.filter((r) => r.status === 'SENT').length, + failed: rows.filter((r) => r.status === 'FAILED').length, + cancelled: rows.filter((r) => r.status === 'CANCELLED').length + }) + ); + } + + /** The day whose notices are open in the popup. */ + queueDetailDate: string | null = null; + + get queueDetail(): KycManagementComponent['queueGroups'][number] | null { + return this.queueDetailDate ? this.queueGroups.find((g) => g.date === this.queueDetailDate) || null : null; + } + + openQueueDate(date: string): void { + this.queueDetailDate = date; + } + + closeQueueDate(): void { + this.queueDetailDate = null; + } + + loadQueue(): void { + this.queueLoading = true; + this.clientsService.getKycNoticeQueue().subscribe({ + next: (rows) => { + this.queueRows = rows || []; + this.queueLoading = false; + this.queueError = ''; + }, + error: () => { + this.queueLoading = false; + this.queueError = 'Could not load the notice queue.'; + } + }); + } + + actOnQueued(id: number, command: 'cancel' | 'retry'): void { + this.clientsService.actOnKycNotice(id, command).subscribe({ + next: () => this.loadQueue(), + error: (err) => + this.toast( + err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'That could not be done.' + ) + }); + } + + /** The queue moves on its own while the scheduler tab is open, so keep it fresh. */ + private startQueuePolling(): void { + this.stopQueuePolling(); + this.loadQueue(); + this.queueTimer = setInterval(() => this.loadQueue(), 10000); + } + + private stopQueuePolling(): void { + if (this.queueTimer) { + clearInterval(this.queueTimer); + this.queueTimer = null; + } + } + + ngOnDestroy(): void { + this.stopQueuePolling(); + } + + loadStatusData(): void { + this.statusLoading = true; + this.statusError = ''; + const today = businessTodayIso(); + + forkJoin({ + lapsed: this.clientsService.getLapsedKyc().pipe( + catchError(() => { + this.statusError = 'Could not load the lapsed KYC list.'; + return of([] as any[]); + }) + ), + expiring: this.clientsService.getExpiringKyc(30).pipe( + catchError(() => { + this.statusError = this.statusError || 'Could not load the list of KYCs expiring soon.'; + return of([] as any[]); + }) + ), + dormant: this.clientsService.getDormantAccounts().pipe( + catchError(() => { + this.statusError = this.statusError || 'Could not load the dormancy list.'; + return of([] as any[]); + }) + ) + }).subscribe(({ lapsed, dormant, expiring }) => { + try { + this.dormancyRows = sortByIdle( + (dormant || []).map((a) => this.mapDormant(a)).filter((r): r is DormancyRow => r !== null) + ); + } catch { + this.dormancyRows = []; + this.statusError = this.statusError || 'Could not read the dormancy list.'; + } + this.dormancyTotals = dormancySummary(this.dormancyRows, today); + + const mapKyc = (list: any[]) => + (list || []) + .map((r: any) => ({ + clientId: r?.clientId, + clientName: r?.clientDisplayName || '—', + accountNo: r?.clientAccountNo || '', + branchCode: r?.clientOfficeName || '', + riskCategory: r?.riskCategory ?? 1, + expiryDate: toIsoDate(r?.expiryDate), + mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''), + email: r?.clientEmailMasked || (r?.hasEmail ? 'On file' : '') + })) + .filter((c) => !!c.expiryDate); + const clients = mapKyc(lapsed); + const soon = mapKyc(expiring); + const everyone = [ + ...clients, + ...soon + ]; + + const finish = (histories: KycContactRow[][]) => { + this.expiryRows = sortWorklist(clients.map((c, i) => buildExpiryRow(c, histories[i] || [], today))); + this.expiringRows = soon + .map((c, i) => buildExpiryRow(c, histories[clients.length + i] || [], today)) + .sort((a, b) => a.expiryDate.localeCompare(b.expiryDate)); + this.expiryTotals = expirySummary(this.expiryRows); + this.statusLoading = false; + }; + + if (!everyone.length) { + finish([]); + return; + } + forkJoin( + everyone.map((c) => + this.clientsService.getKycContactLog(c.clientId).pipe( + map((logs: any[]) => (logs || []).map((l) => this.mapAuditContact(l))), + catchError(() => of([] as KycContactRow[])) + ) + ) + ).subscribe({ + next: finish, + error: () => finish([]) + }); + }); + } + + private mapAuditContact(log: any): KycContactRow { + const status = log?.deliveryStatus || 'NOT_APPLICABLE'; + return { + date: toIsoDate(log?.contactedOn || log?.createdOn), + channel: log?.channel || '', + channelLabel: channelLabel(log?.channel || ''), + notes: log?.notes || '', + officer: log?.officerName || log?.loggedByUsername || '—', + deliveryStatus: status, + delivered: status === 'SENT', + destinationMasked: log?.destinationMasked || '' + }; + } + + private mapDormant(a: any): DormancyRow | null { + const stage = stageFromSubStatus(Number(a?.subStatus)); + if (!stage) { + return null; + } + const lastTxn = toIsoDate(a?.lastCustomerTransactionDate); + const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' }; + return { + accountType: a?.accountType || 'SAVINGS', + accountId: a?.accountId, + accountNo: a?.accountNo || '—', + clientId: a?.clientId, + clientName: a?.clientName || '—', + branchCode: a?.officeName || '', + productName: a?.productName || '—', + balance: Number(a?.balance ?? 0), + lastCustomerTxnDate: lastTxn, + daysIdle: Number(a?.daysIdle ?? 0), + stage, + subStatus: Number(a?.subStatus ?? 0), + nextStageDate: next.date, + nextStageLabel: next.label, + remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined, + escheatedOn: toIsoDate(a?.closedOnDate) || undefined, + remittedOn: toIsoDate(a?.deafRemittedOn) || undefined, + deafReference: a?.deafReference || undefined, + refundedOn: toIsoDate(a?.deafRefundedOn) || undefined, + mobile: a?.mobileNo || '' + }; + } ngOnInit(): void { - this.alertRows = Object.values(this.clients); this.loadExceptions(); + this.loadStatusData(); } loadExceptions(): void { @@ -728,17 +1599,27 @@ export class KycManagementComponent implements OnInit { return this.exceptionRows.filter((c) => c.exceptionReason?.includes('Aadhaar')).length; } - showSection(section: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions'): void { + showSection(section: 'alerts' | 'status' | 'notif' | 'audit' | 'exceptions' | 'expiry' | 'dormancy'): void { this.activeSection = section; + if (section === 'notif') { + this.startQueuePolling(); + } else { + this.stopQueuePolling(); + } + if (section === 'audit' && !this.auditDays.length) { + this.loadAudit(); + } } openContactDialog(client: KmClient): void { this.current = client; + this.contactNoticeType = ''; + this.contactPreview = null; this.contactChannel = 'PHONE_CALL'; this.contactNotes = ''; this.contactError = ''; this.contactSaving = false; - this.contactOfficer = this.bankOfficers[1]; // Neha Verma + this.contactOfficer = ''; this.contactDialogOpen = true; } @@ -787,7 +1668,8 @@ export class KycManagementComponent implements OnInit { .saveKycContactLog(client.id, { channel: this.contactChannel, notes: this.contactNotes.trim(), - officerName: this.contactOfficer + officerName: this.contactOfficer.trim() || undefined, + noticeType: this.contactNoticeType || undefined }) .subscribe({ next: (log: any) => { @@ -805,6 +1687,7 @@ export class KycManagementComponent implements OnInit { this.toast(log?.deliveryMessage || `Contact attempt logged successfully for ${client.name}`); } this.contactDialogOpen = false; + this.loadStatusData(); }, error: (err) => { this.contactSaving = false; @@ -974,23 +1857,6 @@ export class KycManagementComponent implements OnInit { }); } - openModal(id: string): void { - const c = this.clients[id]; - if (!c) return; - this.current = c; - this.modalTab = 'overview'; - this.modalOpen = true; - } - - closeModal(): void { - this.modalOpen = false; - this.current = null; - } - - showMTab(tab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action'): void { - this.modalTab = tab; - } - toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; @@ -998,151 +1864,6 @@ export class KycManagementComponent implements OnInit { this.toastTimer = setTimeout(() => (this.toastVisible = false), 2800); } - // ── Re-KYC dispatch ladder ───────────────────────────────────────────────── - - /** - * Re-KYC outreach is one channel at a time, escalating only when the previous channel has been - * dispatched — not a bundle of channels fired at once. The regulatory 3-notice structure is kept - * as the grouping (notice), while each row is a single dispatch, which is how it actually happens - * at the branch. - * - * status: DELIVERED / FAILED are done, PENDING is the one dispatch that may go out now, LOCKED is - * waiting on an earlier channel. - */ - reKycSteps: { - step: number; - notice: number; - channel: string; - detail: string; - date: string; - offset: string; - status: 'DELIVERED' | 'FAILED' | 'PENDING' | 'LOCKED'; - chip: string; - }[] = [ - { - step: 1, - notice: 1, - channel: 'SMS', - detail: 'Registered mobile', - date: '31 March 2027', - offset: 'Window opens', - status: 'DELIVERED', - chip: 'km-cp-sms' - }, - { - step: 2, - notice: 1, - channel: 'Email', - detail: 'Registered email', - date: '3 April 2027', - offset: '+3 days', - status: 'DELIVERED', - chip: 'km-cp-mail' - }, - { - step: 3, - notice: 2, - channel: 'IVR Call', - detail: 'Outbound IVR', - date: '10 April 2027', - offset: '+10 days', - status: 'PENDING', - chip: 'km-cp-ivr' - }, - { - step: 4, - notice: 2, - channel: 'Reg. Post', - detail: 'India Post, tracked', - date: '14 April 2027', - offset: '+14 days', - status: 'LOCKED', - chip: 'km-cp-post' - }, - { - step: 5, - notice: 3, - channel: 'Home Visit', - detail: 'Field officer, manual', - date: '20 April 2027', - offset: '+20 days', - status: 'LOCKED', - chip: 'km-cp-vis' - }, - { - step: 6, - notice: 3, - channel: 'Video KYC', - detail: 'V-CIP link', - date: '25 April 2027', - offset: '+25 days', - status: 'LOCKED', - chip: 'km-cp-vid' - } - ]; - - /** The single dispatch that may go out now — nothing later is actionable until it is done. */ - get currentReKycStep(): number | null { - return this.reKycSteps.find((s) => s.status === 'PENDING')?.step ?? null; - } - - get reKycCompletedCount(): number { - return this.reKycSteps.filter((s) => s.status === 'DELIVERED' || s.status === 'FAILED').length; - } - - /** First row of a notice carries the notice heading; the rest indent under it. */ - isFirstOfNotice(index: number): boolean { - return index === 0 || this.reKycSteps[index - 1].notice !== this.reKycSteps[index].notice; - } - - reKycStatusLabel(status: string): string { - switch (status) { - case 'DELIVERED': - return '✓ Dispatched'; - case 'FAILED': - return '✗ Failed — escalated'; - case 'PENDING': - return '⏳ Due now'; - default: - return '🔒 Waiting on previous channel'; - } - } - - reKycStatusClass(status: string): string { - switch (status) { - case 'DELIVERED': - return 'km-ok'; - case 'FAILED': - return 'km-fail'; - case 'PENDING': - return 'km-pending'; - default: - return 'km-locked'; - } - } - - /** - * Marks the due channel dispatched and releases only the next one, so the ladder can never skip a - * rung. A failure still advances — the point of escalation is that the next channel is tried. - */ - dispatchReKycStep(step: number, failed = false): void { - const index = this.reKycSteps.findIndex((s) => s.step === step); - if (index < 0 || this.reKycSteps[index].status !== 'PENDING') { - return; - } - const current = this.reKycSteps[index]; - current.status = failed ? 'FAILED' : 'DELIVERED'; - const next = this.reKycSteps[index + 1]; - if (next && next.status === 'LOCKED') { - next.status = 'PENDING'; - } - this.toast( - failed - ? `${current.channel} failed — escalated to ${next ? next.channel : 'freeze workflow'}` - : `${current.channel} dispatched${next ? ` · next: ${next.channel} on ${next.date}` : ''}` - ); - } - // ── Exceptions export ────────────────────────────────────────────────────── /** True while the workbook is being built, so the button cannot be double-fired. */ @@ -1282,209 +2003,7 @@ export class KycManagementComponent implements OnInit { .join(' ; '); } - /** - * Routes a "Take Action" click. Opens the Home Visit dialog if the action is - * a home-visit scheduling action; otherwise falls back to a toast confirmation. - */ - takeAction(action: string): void { - if (!this.current) return; - const lower = action.toLowerCase(); - if (lower.includes('home visit')) { - this.openHomeVisitDialog(); - return; - } - if (lower.includes('video kyc')) { - this.openVkycDialog(); - return; - } - this.toast(action + ' initiated for ' + this.current.name); - } - - openHomeVisitDialog(): void { - this.homeVisitOfficer = ''; - this.homeVisitNote = ''; - // Default to tomorrow at 10:00 AM (datetime-local format: YYYY-MM-DDTHH:mm) - const t = new Date(); - t.setDate(t.getDate() + 1); - t.setHours(10, 0, 0, 0); - const pad = (n: number) => n.toString().padStart(2, '0'); - this.homeVisitDateTime = `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}T${pad(t.getHours())}:${pad(t.getMinutes())}`; - this.homeVisitOpen = true; - } - - closeHomeVisitDialog(): void { - this.homeVisitOpen = false; - } - - saveHomeVisit(): void { - if (!this.current || !this.homeVisitOfficer || !this.homeVisitDateTime) return; - this.scheduledHomeVisits.push({ - client: this.current.name, - address: this.current.contact.address, - officer: this.homeVisitOfficer, - dateTime: this.homeVisitDateTime, - note: this.homeVisitNote - }); - const when = new Date(this.homeVisitDateTime).toLocaleString('en-IN', { - day: '2-digit', - month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - this.toast(`Home Visit scheduled for ${this.current.name} on ${when}`); - this.homeVisitOpen = false; - } - - openVkycDialog(): void { - this.vkycOfficer = ''; - this.vkycMessage = ''; - this.vkycChannels = { sms: true, email: true, whatsapp: false }; - // Default: link expires 48 hours from now (RBI Video-based KYC norms) - const t = new Date(); - t.setHours(t.getHours() + 48); - const pad = (n: number) => n.toString().padStart(2, '0'); - this.vkycExpiry = `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}T${pad(t.getHours())}:${pad(t.getMinutes())}`; - // Generate a session id (token preview only — server would issue the real one) - this.vkycSessionId = 'VKYC-' + Math.random().toString(36).slice(2, 10).toUpperCase(); - this.vkycOpen = true; - } - - closeVkycDialog(): void { - this.vkycOpen = false; - } - - get vkycSelectedChannels(): string[] { - const out: string[] = []; - if (this.vkycChannels.sms) out.push('SMS'); - if (this.vkycChannels.email) out.push('Email'); - if (this.vkycChannels.whatsapp) out.push('WhatsApp'); - return out; - } - - get vkycLink(): string { - return `https://vkyc.bank.example/session/${this.vkycSessionId}`; - } - - saveVkyc(): void { - if (!this.current || !this.vkycOfficer || !this.vkycExpiry) return; - const channels = this.vkycSelectedChannels; - if (channels.length === 0) return; - this.sentVkycLinks.push({ - client: this.current.name, - mobile: this.current.contact.mobile, - email: this.current.contact.email, - officer: this.vkycOfficer, - expiry: this.vkycExpiry, - channels, - sessionId: this.vkycSessionId, - link: this.vkycLink - }); - this.toast(`Video KYC link sent to ${this.current.name} via ${channels.join(' + ')}`); - this.vkycOpen = false; - } - - /** Compliance journey stepper state for a given client */ - getStepperState(c: KmClient): { label: string; state: 'done' | 'pending' | 'current' | 'danger' }[] { - return [ - { label: 'KYC Active', state: 'done' }, - { label: 'Alert Sent', state: c.noticesSent >= 1 ? 'done' : 'pending' }, - { label: 'Notice 2', state: c.noticesSent >= 2 ? 'done' : c.noticesSent >= 1 ? 'current' : 'pending' }, - { - label: 'Notice 3', - state: - c.noticesSent >= 3 ? 'done' : c.noticesSent >= 2 ? (c.state === 'urgent' ? 'danger' : 'current') : 'pending' - }, - { - label: 'Debit Freeze', - state: [ - 'freeze', - 'inop', - 'dormant' - ].includes(c.state) ? 'done' : c.noticesSent >= 3 ? 'danger' : 'pending' - }, - { label: 'Inoperative', state: [ - 'inop', - 'dormant' - ].includes(c.state) ? 'done' : 'pending' }, - { label: 'Dormant', state: c.state === 'dormant' ? 'danger' : 'pending' } - ]; - } - - stepIcon(state: string): string { - if (state === 'done') return '✓'; - if (state === 'danger') return '!'; - return '·'; - } - - stateLabel(state: string): string { - return ( - ( - { - alert: 'Alert Window', - freeze: 'Debit Freeze', - inop: 'Inoperative', - dormant: 'Dormant', - urgent: 'Urgent Alert' - } as Record - )[state] || state - ); - } - - stateClass(state: string): string { - return ( - ( - { - alert: 'sp-al', - freeze: 'sp-fr', - inop: 'sp-in', - dormant: 'sp-do', - urgent: 'sp-fr' - } as Record - )[state] || 'sp-al' - ); - } - riskClass(risk: string): string { return ({ low: 'b-gr', mid: 'b-am', high: 'b-re' } as Record)[risk] || 'b-gy'; } - - /** Notices pending count (cap at 0) */ - noticesPending(c: KmClient): number { - return Math.max(0, 3 - c.noticesSent); - } - - deliveryClass(d: string): string { - const m: Record = { - Delivered: 'd-ok', - 'In Transit': 'd-tr', - 'No Response': 'd-fa', - Pending: 'd-pe', - 'Visit Done': 'd-ok', - 'Not Found': 'd-fa' - }; - return m[d] || 'd-pe'; - } - - barWidth(c: KmClient): number { - if (c.daysLeft <= 0) return 100; - return Math.max(2, Math.min(100, c.daysLeft * 3)); - } - - barColor(c: KmClient): string { - if (c.daysLeft <= 0) return 'var(--km-g400)'; - return c.daysLeft > 10 ? 'var(--km-amber)' : 'var(--km-red)'; - } - - responseClass(r: string): string { - const m: Record = { - 'No Action': 'd-pe', - 'No Response': 'd-fa', - Pending: 'd-pe', - Refused: 'd-fa', - 'Not Found at Address': 'd-fa', - Awaited: 'd-pe' - }; - return m[r] || 'd-pe'; - } } diff --git a/src/app/organization/institution-profile/institution-profile.component.html b/src/app/organization/institution-profile/institution-profile.component.html index a4d4f79a2..7d867579d 100644 --- a/src/app/organization/institution-profile/institution-profile.component.html +++ b/src/app/organization/institution-profile/institution-profile.component.html @@ -144,15 +144,15 @@ PAN - + TAN - + GSTIN - +
diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts index db05e8a75..651e72d10 100644 --- a/src/app/organization/institution-profile/institution-profile.component.ts +++ b/src/app/organization/institution-profile/institution-profile.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { catchError, of } from 'rxjs'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RestrictInputDirective } from '../../directives/restrict-input.directive'; import { OrganizationService } from '../organization.service'; import { SettingsService } from 'app/settings/settings.service'; import { DISABLED_ACCOUNTING_FEATURES } from '../../accounting/disabled-features'; @@ -57,7 +58,10 @@ const STATUSES = [ selector: 'mifosx-institution-profile', templateUrl: './institution-profile.component.html', styleUrls: ['./institution-profile.component.scss'], - imports: [...STANDALONE_SHARED_IMPORTS] + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RestrictInputDirective + ] }) export class InstitutionProfileComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 22b411aff..40dadf490 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -308,6 +308,13 @@ const routes: Routes = [ loadComponent: () => import('./tds-config/tds-config.component').then((m) => m.TdsConfigComponent), data: { title: 'TDS Configuration', breadcrumb: 'TDS Configuration' } }, + { + // Reserve funds and the percentage of year-end net profit appropriated to each, with their GL accounts. + path: 'reserve-fund-config', + loadComponent: () => + import('./reserve-fund-config/reserve-fund-config.component').then((m) => m.ReserveFundConfigComponent), + data: { title: 'Reserve Fund Configuration', breadcrumb: 'Reserve Fund Configuration' } + }, { // OSS 1 keeps its own screen: sixteen roles of its own, none shared with CRR or SLR. path: 'oss1-gl-config', diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index c19767a54..9715445f4 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -104,6 +104,26 @@
+ +
+
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index c89ee7739..2d63e6728 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -1339,4 +1339,38 @@ export class OrganizationService { deleteRbiReserveRate(rateId: number): Observable { return this.http.delete(`/rbireserveconfig/rate/${rateId}`); } + + /** + * @returns {Observable} `{ reserves[], totalPercentage, remainingPercentage, updatedOn, updatedBy }` — each + * reserve fund, the GL account it is credited to (may be unset) and its percentage of the year's net profit. + */ + getProfitAppropriationConfig(): Observable { + return this.http.get('/profitappropriationconfig'); + } + + /** @returns {Observable} `{ glAccountOptions[] }` — postable equity and liability accounts. */ + getProfitAppropriationConfigTemplate(): Observable { + return this.http.get('/profitappropriationconfig/template'); + } + + /** Replaces the whole list of reserve funds; the list as sent is exactly what is stored. */ + updateProfitAppropriationConfig( + reserves: { name: string; glAccountId: number | null; percentage: number }[] + ): Observable { + return this.http.put('/profitappropriationconfig', { reserves }); + } + + /** + * What appropriating a year's profit into the reserves would post (or, once posted, what was posted). + * Without a year, the backend picks the newest closed year not yet appropriated. + */ + getProfitAppropriationPosting(financialYear?: string): Observable { + const query = financialYear ? `?financialYear=${encodeURIComponent(financialYear)}` : ''; + return this.http.get(`/profitappropriationconfig/posting${query}`); + } + + /** Posts the year's appropriation: Dr profit GL / Cr each reserve GL, as one journal entry. */ + postProfitAppropriation(financialYear: string, note: string | null): Observable { + return this.http.post('/profitappropriationconfig/posting', { financialYear, note }); + } } diff --git a/src/app/organization/reserve-fund-config/reserve-fund-config.component.html b/src/app/organization/reserve-fund-config/reserve-fund-config.component.html new file mode 100644 index 000000000..8ef9b086d --- /dev/null +++ b/src/app/organization/reserve-fund-config/reserve-fund-config.component.html @@ -0,0 +1,359 @@ + + +
+
+ +

Reserve Fund Configuration

+
+

+ The reserve funds the bank keeps, the percentage of each year's net profit appropriated to each, and the GL account + each one is credited to. Whatever is not appropriated stays in profit. +

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

Reserve funds

+

+ Only postable equity and liability accounts are listed, and each account can back one reserve only. A reserve + can be saved before its GL account exists; it cannot receive an appropriation until one is selected. +

+ + @if (rows.length) { +
+ + + + + + + + + + + + @for (row of rows; track row.rowId; let i = $index) { + + + + + + + + } + + + + + + + + + + + + + + + +
#Reserve / fundCredited to (GL) + % of profit + @if (profitSource.state === 'mapped') { + from {{ profitSource.glCode }} + } +
{{ i + 1 }} + + + @if (isNameDuplicate(row)) { + Listed twice + } + + + + + + + + — Not selected — + @for (o of availableAccounts(row); track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + @if (row.glAccountId == null) { + GL not selected + } + + + + + % + + + +
Total appropriated{{ totalPercentage }}%
Remaining in profit{{ remainingPercentage }}%
+
+ } @else { +
No reserve fund is configured yet. Add one to begin.
+ } + + @if (overLimit) { +
+ The reserves add up to {{ totalPercentage }}% of net profit. The total cannot exceed 100%. +
+ } + @if (unmappedCount > 0) { +
+ {{ unmappedCount }} reserve{{ unmappedCount === 1 ? ' has' : 's have' }} no GL account yet. Create the account + in Chart of Accounts if needed, then select it here. +
+ } + +
+ + + @if (dirty && !saving) { + + } + @if (updatedOn) { + + Last saved {{ updatedOn | slice: 0 : 16 }} + @if (updatedBy) { + by {{ updatedBy }} + } + + } +
+
+ + +
+

Post to reserves

+

+ Moves a closed year's profit out of the profit GL into the reserve GLs above, at their percentages — one journal + entry, dated the last day of the year. Each year can be posted once. +

+ + @if (postingError) { +
{{ postingError }}
+ } + @if (postingSuccess) { +
{{ postingSuccess }}
+ } + +
+ + Financial year + + @for (fy of posting?.financialYearOptions || []; track fy) { + FY {{ fy }} + } + + + + @if (posting) { +
+ Profit in {{ posting.profitGlCode || 'profit GL' }} + ₹{{ posting.profitAmount | number: '1.2-2' }} +
+
+ @if (posting.posted) { + Posted + } @else if (posting.yearClosed) { + Year closed + } @else { + Year not closed + } +
+ } +
+ + @if (postingLoading) { +
Loading…
+ } @else if (posting) { +
+ + + + + + + + + + + @for (l of posting.lines || []; track $index) { + + + + + + + } + + + + + + + + + + + +
Reserve / fundCredited to (GL)%Amount
{{ l.reserveName }} + @if (l.glCode) { + {{ l.glCode }} · {{ l.glAccountName }} + } @else { + GL not selected + } + {{ l.percentage }}%₹{{ l.amount | number: '1.2-2' }}
{{ posting.posted ? 'Posted to reserves' : 'To be posted to reserves' }}₹{{ posting.totalAmount | number: '1.2-2' }}
Remaining in {{ posting.profitGlCode || 'profit GL' }}₹{{ posting.remainingAmount | number: '1.2-2' }}
+
+ + @if (posting.posted) { +

+ Posted on {{ posting.postedOnDate }} by {{ posting.postedBy || '—' }} · Transaction + {{ posting.glTransactionId }} + @if (posting.note) { + · {{ posting.note }} + } +

+ } @else { + @for (b of posting.blockers || []; track $index) { +
{{ b }}
+ } + @if (dirty) { +
+ Save the reserve changes above first — posting uses the saved configuration. +
+ } + +
+ + Note (e.g. AGM / board resolution no.) + + + @if (!postingConfirm) { + + } @else { + + Post ₹{{ posting.totalAmount | number: '1.2-2' }} from {{ posting.profitGlCode }} for FY + {{ postingYear }}? This cannot be undone here. + + + + } +
+ } + } +
+ } +
diff --git a/src/app/organization/reserve-fund-config/reserve-fund-config.component.scss b/src/app/organization/reserve-fund-config/reserve-fund-config.component.scss new file mode 100644 index 000000000..3b2c68752 --- /dev/null +++ b/src/app/organization/reserve-fund-config/reserve-fund-config.component.scss @@ -0,0 +1,143 @@ +/** + * 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. */ + +.rfc-source { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + + &__icon { + flex: 0 0 auto; + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + background: #fef3c7; + color: #b45309; + } + + &__body { + flex: 1 1 260px; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + &__label { + font-size: 0.72rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgb(0 0 0 / 55%); + font-weight: 600; + } + + &__gl { + font-size: 15px; + font-weight: 600; + } + + &__link { + flex: 0 0 auto; + } +} + +/* Four columns do not fit a phone; the table scrolls inside its card rather than widening the page. */ +.rfc-table-wrap { + overflow-x: auto; +} + +.rfc-table { + min-width: 600px; +} + +.rfc-post-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px 24px; + margin-bottom: 8px; + + &__fy { + width: 180px; + } + + &__profit { + display: flex; + flex-direction: column; + gap: 2px; + } +} + +.rfc-post-amt { + width: 10rem; + text-align: right; + white-space: nowrap; +} + +.rfc-post-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 12px; + + &__note { + flex: 1 1 280px; + max-width: 480px; + } + + &__confirm { + font-weight: 600; + color: #b45309; + } +} + +.rfc-th-sub { + display: block; + text-transform: none; + letter-spacing: 0; + font-weight: 500; +} + +.rfc-sr { + width: 2rem; + color: rgb(0 0 0 / 55%); +} + +.rfc-warn { + color: #b45309; +} + +.rfc-error { + color: #b91c1c; +} + +.rfc-total td, +.rfc-remaining td { + padding-top: 0.6rem; + font-weight: 600; +} + +.rfc-total td { + border-top: 1px solid rgb(0 0 0 / 12%); +} + +.rfc-total--over td { + color: #b91c1c; +} + +.rfc-remaining td { + font-weight: 500; + color: rgb(0 0 0 / 60%); +} diff --git a/src/app/organization/reserve-fund-config/reserve-fund-config.component.ts b/src/app/organization/reserve-fund-config/reserve-fund-config.component.ts new file mode 100644 index 000000000..2d010516c --- /dev/null +++ b/src/app/organization/reserve-fund-config/reserve-fund-config.component.ts @@ -0,0 +1,308 @@ +/** + * 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 { forkJoin } from 'rxjs'; +import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; +import { AccountingService } from 'app/accounting/accounting.service'; + +/** Financial activity whose GL holds the year's closed-out profit (FinancialActivity.DISTRIBUTABLE_PROFIT). */ +const DISTRIBUTABLE_PROFIT_ACTIVITY = 302; + +/** Where the appropriated profit is taken from, as shown at the top of the screen. */ +type ProfitSource = + | { state: 'loading' } + | { state: 'mapped'; mappingId: number; glCode: string; glName: string } + | { state: 'unmapped' } + | { state: 'unknown' }; + +/** One reserve fund on screen, as a working copy - committed on Save. */ +interface ReserveRow { + /** Screen-only identity, so deleting a row never shifts the inputs bound to the rows after it. */ + rowId: number; + name: string; + glAccountId: number | null; + percentage: number | null; +} + +interface GlOption { + id: number; + name: string; + glCode: string; +} + +/** + * Reserve funds (Statutory Reserve, Building Fund, Investment Fluctuation Fund...) and the percentage of the + * year's net profit appropriated to each, with the GL account each one is credited to. + * + * The list is saved as a whole: what is on screen when Save is pressed is exactly what is stored. + */ +@Component({ + selector: 'mifosx-reserve-fund-config', + templateUrl: './reserve-fund-config.component.html', + styleUrls: [ + '../rbi-reserve-config/rbi-reserve-config.component.scss', + './reserve-fund-config.component.scss' + ], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + NgxMatSelectSearchModule + ] +}) +export class ReserveFundConfigComponent implements OnInit { + private organizationService = inject(OrganizationService); + private accountingService = inject(AccountingService); + + loading = true; + saving = false; + errorMsg = ''; + successMsg = ''; + + rows: ReserveRow[] = []; + glAccountOptions: GlOption[] = []; + accountSearch = ''; + updatedOn: string | null = null; + updatedBy: string | null = null; + profitSource: ProfitSource = { state: 'loading' }; + + /** "Post to reserves": the chosen year's preview, or what was posted for it. */ + posting: any = null; + postingYear = ''; + postingLoading = false; + postingBusy = false; + postingConfirm = false; + postingNote = ''; + postingError = ''; + postingSuccess = ''; + + private nextRowId = 1; + private savedSnapshot = '[]'; + + ngOnInit(): void { + forkJoin({ + config: this.organizationService.getProfitAppropriationConfig(), + template: this.organizationService.getProfitAppropriationConfigTemplate() + }).subscribe({ + next: ({ config, template }) => { + this.glAccountOptions = template?.glAccountOptions || []; + this.applyConfig(config); + this.loading = false; + }, + error: (err) => { + this.errorMsg = this.messageFrom(err, 'Could not load the reserve fund configuration.'); + this.loading = false; + } + }); + this.loadProfitSource(); + this.loadPosting(); + } + + /** Loads the posting preview for a year; with none, the backend picks the year to open on. */ + loadPosting(financialYear?: string): void { + this.postingLoading = true; + this.postingConfirm = false; + this.postingError = ''; + this.organizationService.getProfitAppropriationPosting(financialYear).subscribe({ + next: (preview) => { + this.posting = preview; + this.postingYear = preview?.financialYear ?? ''; + this.postingLoading = false; + }, + error: (err) => { + this.postingError = this.messageFrom(err, 'Could not load the appropriation for this year.'); + this.postingLoading = false; + } + }); + } + + /** Unsaved reserve edits are not what would be posted - the backend posts the saved configuration. */ + get canPost(): boolean { + return !!this.posting?.canPost && !this.dirty && !this.postingBusy; + } + + post(): void { + if (!this.canPost) { + return; + } + this.postingBusy = true; + this.postingError = ''; + this.postingSuccess = ''; + this.organizationService.postProfitAppropriation(this.postingYear, this.postingNote.trim() || null).subscribe({ + next: (result) => { + this.posting = result; + this.postingBusy = false; + this.postingConfirm = false; + this.postingNote = ''; + this.postingSuccess = `Profit for FY ${this.postingYear} posted to the reserves (${result?.glTransactionId}).`; + }, + error: (err) => { + this.postingBusy = false; + this.postingConfirm = false; + this.postingError = this.messageFrom(err, 'Could not post the appropriation.'); + } + }); + } + + /** + * The profit GL is not chosen here: it is the account mapped to "Distributable Profit" under Accounting → + * Accounts Linked to Financial Activities, which the year-end close posts net profit into. Loaded on its own so + * a user without access to the mappings still gets the rest of the screen. + */ + private loadProfitSource(): void { + this.accountingService.getFinancialActivityAccounts().subscribe({ + next: (mappings: any[]) => { + const profit = (mappings || []).find((m) => m?.financialActivityData?.id === DISTRIBUTABLE_PROFIT_ACTIVITY); + this.profitSource = profit?.glAccountData + ? { + state: 'mapped', + mappingId: profit.id, + glCode: profit.glAccountData.glCode, + glName: profit.glAccountData.name + } + : { state: 'unmapped' }; + }, + error: () => (this.profitSource = { state: 'unknown' }) + }); + } + + addRow(): void { + this.rows = [ + ...this.rows, + { rowId: this.nextRowId++, name: '', glAccountId: null, percentage: null }]; + this.successMsg = ''; + } + + removeRow(row: ReserveRow): void { + this.rows = this.rows.filter((r) => r.rowId !== row.rowId); + this.successMsg = ''; + } + + /** Accounts a row may pick: matching the search, and not already credited by another reserve. */ + availableAccounts(row: ReserveRow): GlOption[] { + const taken = new Set( + this.rows.filter((r) => r.rowId !== row.rowId && r.glAccountId != null).map((r) => r.glAccountId) + ); + const term = this.accountSearch.trim().toLowerCase(); + return this.glAccountOptions.filter( + (o) => + (!taken.has(o.id) || o.id === row.glAccountId) && + (!term || o.glCode?.toLowerCase().includes(term) || o.name?.toLowerCase().includes(term)) + ); + } + + get totalPercentage(): number { + return this.round(this.rows.reduce((sum, r) => sum + (Number(r.percentage) || 0), 0)); + } + + get remainingPercentage(): number { + return this.round(100 - this.totalPercentage); + } + + get overLimit(): boolean { + return this.totalPercentage > 100; + } + + get unmappedCount(): number { + return this.rows.filter((r) => r.glAccountId == null).length; + } + + isNameDuplicate(row: ReserveRow): boolean { + const name = row.name.trim().toLowerCase(); + return !!name && this.rows.some((r) => r.rowId !== row.rowId && r.name.trim().toLowerCase() === name); + } + + isRowInvalid(row: ReserveRow): boolean { + const pct = Number(row.percentage); + return ( + !row.name.trim() || + row.name.trim().length > 100 || + this.isNameDuplicate(row) || + row.percentage == null || + !(pct > 0) || + pct > 100 + ); + } + + get dirty(): boolean { + return this.snapshot(this.rows) !== this.savedSnapshot; + } + + get canSave(): boolean { + return !this.saving && this.dirty && !this.overLimit && !this.rows.some((r) => this.isRowInvalid(r)); + } + + save(): void { + if (!this.canSave) { + return; + } + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + const payload = this.rows.map((r) => ({ + name: r.name.trim(), + glAccountId: r.glAccountId, + percentage: Number(r.percentage) + })); + this.organizationService.updateProfitAppropriationConfig(payload).subscribe({ + next: (config) => { + this.applyConfig(config); + this.saving = false; + this.successMsg = 'Reserve fund configuration saved.'; + this.loadPosting(this.postingYear || undefined); + }, + error: (err) => { + this.saving = false; + this.errorMsg = this.messageFrom(err, 'Could not save the reserve fund configuration.'); + } + }); + } + + /** Throws away unsaved edits. */ + reset(): void { + this.rows = JSON.parse(this.savedSnapshot).map((r: Omit) => ({ + ...r, + rowId: this.nextRowId++ + })); + this.errorMsg = ''; + this.successMsg = ''; + } + + private applyConfig(config: any): void { + this.rows = (config?.reserves || []).map((r: any) => ({ + rowId: this.nextRowId++, + name: r.name ?? '', + glAccountId: r.glAccountId ?? null, + percentage: r.percentage != null ? Number(r.percentage) : null + })); + this.savedSnapshot = this.snapshot(this.rows); + this.updatedOn = config?.updatedOn ?? null; + this.updatedBy = config?.updatedBy ?? null; + } + + private snapshot(rows: ReserveRow[]): string { + return JSON.stringify( + rows.map((r) => ({ + name: r.name.trim(), + glAccountId: r.glAccountId, + percentage: r.percentage == null ? null : Number(r.percentage) + })) + ); + } + + private round(value: number): number { + return Math.round(value * 10000) / 10000; + } + + private messageFrom(err: any, fallback: string): string { + return err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || fallback; + } +} 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 f01407d85..eb0b1276b 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 @@ -452,15 +452,22 @@ @if (isCashOrAccrualAccounting()) { +

GL Mapping

+
+ Accounting Rule + {{ accountingRule?.value }} +
+
Assets
- Overdraft portfolio control + Overdraft Portfolio Control {{ formatGlAccountById( CurrentProduct.overdraftPortfolioControlId, @@ -468,39 +475,41 @@ ) }}
-
- Interest receivable account - {{ - formatGlAccountById( - CurrentProduct.interestReceivableAccountId, - CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
-
- Fees receivable account - {{ - formatGlAccountById( - CurrentProduct.feesReceivableAccountId, - CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
-
- Penalties receivable account - {{ - formatGlAccountById( - CurrentProduct.penaltiesReceivableAccountId, - CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
+ @if (isAccrualAccounting()) { +
+ Interest Receivable Account + {{ + formatGlAccountById( + CurrentProduct.interestReceivableAccountId, + CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions + ) + }} +
+
+ Fees Receivable Account + {{ + formatGlAccountById( + CurrentProduct.feesReceivableAccountId, + CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions + ) + }} +
+
+ Penalties Receivable Account + {{ + formatGlAccountById( + CurrentProduct.penaltiesReceivableAccountId, + CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions + ) + }} +
+ }
Liabilities
- Current control account + Current Control Account {{ formatGlAccountById( CurrentProduct.savingsControlAccountId, @@ -508,18 +517,20 @@ ) }}
-
- Interest payable account - {{ - formatGlAccountById( - CurrentProduct.interestPayableAccountId, - CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions - ) - }} -
+ @if (isAccrualAccounting()) { +
+ Interest Payable Account + {{ + formatGlAccountById( + CurrentProduct.interestPayableAccountId, + CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions + ) + }} +
+ } @if (CurrentProduct.isDormancyTrackingActive) {
- Escheat liability account + Escheat Liability Account {{ formatGlAccountById( CurrentProduct.escheatLiabilityId, @@ -533,7 +544,7 @@
Income
- Income from fees + Income From Fees {{ formatGlAccountById( CurrentProduct.incomeFromFeeAccountId, @@ -542,7 +553,7 @@ }}
- Income from penalties + Income From Penalties {{ formatGlAccountById( CurrentProduct.incomeFromPenaltyAccountId, @@ -551,7 +562,7 @@ }}
- Income from interest + Income From Interest {{ formatGlAccountById( CurrentProduct.incomeFromInterestId, diff --git a/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.html b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.html index 90a6902c9..0700b3c51 100644 --- a/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.html +++ b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.html @@ -79,12 +79,25 @@ - Applies To + Account Type - {{ a }} + {{ a.label }} + Which accounts the rule watches + + Direction + + {{ d.label }} + + Credit = paisa aaya, Debit = paisa gaya + + +
+ Cash transactions only +
+ AML Check Status @@ -105,6 +118,14 @@ Maximum limit cannot be lower than the minimum — the rule would never match.
+
+ info_outline + + This rule will be listed under the CC chip. It does not fire on live transactions yet: the engine is not yet fed + current-account transactions. + +
+

Amount or window details also create the matching live-transaction rule. Leave them blank for a profile-only rule.

diff --git a/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.scss b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.scss index 4396ffb15..4f10fe121 100644 --- a/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.scss +++ b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.scss @@ -86,3 +86,32 @@ grid-template-columns: minmax(0, 1fr); } } + +.rule-cash-only { + display: flex; + align-items: center; + min-height: 56px; +} + +.rule-form-info { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 8px 0 0; + padding: 10px 14px; + border: 1px solid #bfdbfe; + border-left: 4px solid #1d4ed8; + border-radius: 8px; + background: #eff6ff; + color: #1e3a8a; + font-size: 12.5px; + line-height: 1.45; + + mat-icon { + flex-shrink: 0; + font-size: 18px; + width: 18px; + height: 18px; + color: #1d4ed8; + } +} diff --git a/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.ts b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.ts index fee1a4d17..5779ff80f 100644 --- a/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.ts +++ b/src/app/rbi-circulars/aml-rules/add-aml-rule-dialog/add-aml-rule-dialog.component.ts @@ -13,6 +13,7 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; @@ -41,6 +42,7 @@ import { ClientsService } from '../../../clients/clients.service'; MatFormFieldModule, MatInputModule, MatSelectModule, + MatCheckboxModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule @@ -74,11 +76,23 @@ export class AddAmlRuleDialogComponent { 'STRUCTURING', 'VELOCITY' ]; - readonly appliesToOptions = [ - 'ALL', - 'SAVINGS', - 'LOAN', - 'TRANSFER' + /** + * Which kind of account the rule is for. CC is a Cash Credit account (a current account with an overdraft): the engine is + * not yet fed current-account transactions, so a CC rule is listed under the CC chip but does not fire until it is. + */ + readonly accountTypes = [ + { value: 'ALL', label: 'All accounts' }, + { value: 'SAVINGS', label: 'Savings' }, + { value: 'LOAN', label: 'Loan' }, + { value: 'TRANSFER', label: 'Transfer' }, + { value: 'CURRENT', label: 'CC (Cash Credit)' } + ]; + + /** Which way the money moves for the rule to apply. Left open, it applies to both. */ + readonly directionOptions = [ + { value: '', label: 'Credit and debit (both)' }, + { value: 'CREDIT', label: 'Credit only (money comes in)' }, + { value: 'DEBIT', label: 'Debit only (money goes out)' } ]; readonly amlCheckStatuses = [ 'CLEAR', @@ -112,6 +126,8 @@ export class AddAmlRuleDialogComponent { txnWindowDays: [0], txnRuleType: ['THRESHOLD'], txnAppliesTo: ['ALL'], + txnDirection: [''], + txnCashOnly: [false], txnMinLimitAmount: [ null as | number @@ -131,6 +147,11 @@ export class AddAmlRuleDialogComponent { dormantFlag: ['ACTIVE'] }); + /** A CC rule is catalogued now and starts firing once current-account transactions reach the engine. */ + get isCcRule(): boolean { + return this.ruleForm.value.txnAppliesTo === 'CURRENT'; + } + /** Mirrors the backend band check so the officer is told before the round trip. */ get bandInverted(): boolean { const min = this.ruleForm.value.txnMinLimitAmount; @@ -179,6 +200,9 @@ export class AddAmlRuleDialogComponent { txnWindowDays: v.txnWindowDays ? v.txnWindowDays : null, txnRuleType: v.txnRuleType, txnAppliesTo: v.txnAppliesTo, + txnDirection: v.txnDirection ? v.txnDirection : null, + txnCashOnly: !!v.txnCashOnly, + ccRelevant: v.txnAppliesTo === 'CURRENT', txnMinLimitAmount: amount(v.txnMinLimitAmount), txnMaxLimitAmount: amount(v.txnMaxLimitAmount) }; diff --git a/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.html b/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.html new file mode 100644 index 000000000..9e86e02be --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.html @@ -0,0 +1,595 @@ + + +
+ account_balance_wallet +
+ A Cash Credit (CC) account is a current account that may draw an overdraft against a sanctioned limit. For each one, + this shows how much came in and how much went out, against the turnover the customer declared and the limit the bank + sanctioned. It is read from the posted transactions, so it is live now, while the scenarios for CC are still being + added to the engine. +
+
+ +
+ + + + @if (asOf) { + {{ from | date: 'dd MMM yyyy' }} to {{ asOf | date: 'dd MMM yyyy' }} + } + + +
+ +
+
+
₹{{ totalIn | number: '1.2-2' }}
+
Came in
+
+
+
₹{{ totalOut | number: '1.2-2' }}
+
Went out
+
+
+
{{ shown.length }}
+
CC accounts
+
+
+ +@if (loading) { +
Loading the CC account figures…
+} @else if (error) { +
{{ error }}
+} @else if (!shown.length) { +
+ No CC account in your branch yet. A CC account is a current account opened on a product that allows an overdraft + (Client page, Applications, New CC Account). +
+} @else { +
+ + + + + + + + + + + + + + + + + + + + + @for (a of shown; track a.accountId; let i = $index) { + + + + + + + + + + + + + + + + + } + +
#Customer / AccountBranchLimitDrawnDeclared / monthCame inWent outNetIn ÷ declaredCash inLast txnCC rulesDetails
{{ i + 1 }} +
+ {{ a.clientName }} + {{ a.isCc ? 'CC' : 'Current' }} +
+
{{ a.accountNo }}
+
{{ a.branchName || '—' }}{{ a.isCc ? '₹' + (a.sanctionedLimit | number: '1.0-0') : '—' }}{{ a.limitDrawnPercent != null ? a.limitDrawnPercent + '%' : '—' }} + {{ + a.declaredMonthlyTurnover != null ? '₹' + (a.declaredMonthlyTurnover | number: '1.0-0') : 'not declared' + }} + + ₹{{ a.credits | number: '1.2-2' }} +
{{ a.creditCount }} txn
+
+ ₹{{ a.debits | number: '1.2-2' }} +
{{ a.debitCount }} txn
+
{{ rs(a.net) }} + @if (a.creditsVsDeclared != null) { + + {{ a.creditsVsDeclared }}× + + } @else { + — + } + {{ a.cashCreditPercent != null ? a.cashCreditPercent + '%' : '—' }}{{ a.lastTxnDate ? (a.lastTxnDate | date: 'dd MMM yyyy') : '—' }} + {{ a.rulesHit }} hit +
{{ a.rulesNoHit }} no hit · {{ a.rulesNotChecked }} not checked
+
+ + +
+
+

+ Busiest first. “In ÷ declared” compares what came in with the turnover the customer declared, scaled to the window; + {{ spikeMultiple }}× or more is highlighted for a look. It is an indicative starting value, not a finding. +

+} + + + +
+ @if (trailLoading) { +
Loading the account's money trail…
+ } @else if (trailError) { +
{{ trailError }}
+
+ } @else if (trail) { +
+
+
+ {{ trail.clientName }} + {{ trail.isCc ? 'CC' : 'Current' }} +
+
{{ trail.accountNo }} · {{ trail.branchName }}
+
+ {{ trail.from | date: 'dd MMM yyyy' }} to {{ trail.asOf | date: 'dd MMM yyyy' }} · Sanctioned limit ₹{{ + trail.sanctionedLimit | number: '1.0-0' + }} + · Closing balance {{ drcr(trail.balance) }} +
+
+ +
+ +
+ Cr (credit) = money received into the account. + Dr (debit) = money paid out of the account. + On a CC account, a Dr balance means the customer has drawn on the overdraft, + that is, owes the bank. +
+ +
+ + + +
+ + @if (trailView === 'rules') { + +
+ + + + +
+

+ Hit = this account's transactions trigger the rule. No hit = the rule was + checked and not triggered. Not checked = the rule needs an officer's judgement or data the + system does not hold yet, so it is not shown as passed. +

+
+ + + + + + + + + + + + @for (r of filteredRuleChecks; track r.ruleId) { + + + + + + + + } @empty { + + + + } + +
RuleKindRiskResultWhy
+ {{ r.name }} + @if (r.onTrial) { + TRIAL + } + {{ r.kind }}{{ riskLabel(r.riskCategory) }} + + {{ r.status === 'HIT' ? 'HIT' : r.status === 'NO_HIT' ? 'No hit' : 'Not checked' }} + + {{ r.detail }}
No rule in this group.
+
+ } @else if (trailView === 'analysis') { + +
+
In short
+ @for (line of story; track $index) { +

{{ line }}

+ } +
+ + +
+
+
Total credits (Cr)
+
₹{{ trail.summary.in | number: '1.2-2' }}
+
+ {{ trail.summary.inCount }} credit{{ trail.summary.inCount === 1 ? '' : 's' }} · money received +
+
+
+
Total debits (Dr)
+
₹{{ trail.summary.out | number: '1.2-2' }}
+
+ {{ trail.summary.outCount }} debit{{ trail.summary.outCount === 1 ? '' : 's' }} · money paid out +
+
+
+
Net (Cr − Dr)
+
+ {{ drcr(trail.summary.net) }} +
+
+ {{ + trail.summary.net < 0 + ? 'Debits were more than credits' + : trail.summary.net > 0 + ? 'Credits were more than debits' + : 'Credits equal debits' + }} +
+
+
+
Closing balance
+
+ {{ drcr(trail.balance) }} +
+
+ {{ + trail.balance < 0 + ? 'Overdrawn: the customer owes the bank' + : trail.balance > 0 + ? "The customer's own money" + : 'Nil' + }} +
+
+
+
Lowest balance
+
{{ drcr(trail.summary.lowestBalance, '1.0-0') }}
+
+ {{ + trail.summary.peakLimitUsedPercent != null + ? trail.summary.peakLimitUsedPercent + '% of the limit used at the worst point' + : 'Never overdrawn' + }} +
+
+
+ +
+ Active days: {{ trail.summary.activeDays }} + Average credit: + {{ + trail.summary.averageIn != null ? '₹' + (trail.summary.averageIn | number: '1.0-0') : '—' + }} + Average debit: + {{ + trail.summary.averageOut != null ? '₹' + (trail.summary.averageOut | number: '1.0-0') : '—' + }} + + Declared turnover for the window: + {{ + trail.summary.declaredForWindow != null + ? '₹' + (trail.summary.declaredForWindow | number: '1.0-0') + : 'not declared' + }} + + + Credits ÷ declared: + {{ trail.summary.creditsVsDeclared != null ? trail.summary.creditsVsDeclared + '×' : '—' }} + +
+ + +
What stands out
+
+ @for (o of trail.observations; track $index) { +
+ {{ o.level }} +
+
{{ o.title }}
+
{{ o.detail }}
+ @if (o.why) { +
Why it matters: {{ o.why }}
+ } +
+
+ } +
+ +
+ +
+
By mode of transaction
+ + + + + + + + + + + + + @for (m of trail.byMode; track m.label) { + + + + + + + + + } @empty { + + + + } + +
ModeCredit (Cr)Debit (Dr)NetTxnsShare
{{ m.label }}₹{{ m.in | number: '1.0-0' }}₹{{ m.out | number: '1.0-0' }}{{ drcr(m.in - m.out, '1.0-0') }}{{ m.count }} + + {{ m.sharePercent }}% +
No transactions in this window.
+

Share = this mode's part of all the money that moved (credits + debits).

+
+ + +
+
Who the money came from (Cr) and went to (Dr)
+ + + + + + + + + + + + + @for (p of trail.byCounterparty; track $index) { + + + + + + + + + } @empty { + + + + } + +
Account / BankCredit (Cr)Debit (Dr)NetTxnsShare
+ {{ p.label }} +
{{ p.sub || '' }}
+
₹{{ p.in | number: '1.0-0' }}₹{{ p.out | number: '1.0-0' }}{{ drcr(p.in - p.out, '1.0-0') }}{{ p.count }} + + {{ p.sharePercent }}% +
—
+

Cr from a party = it paid this account. Dr to a party = this account paid it.

+
+
+ + +
Week by week
+ + + + + + + + + + + + @for (w of trail.timeline; track w.label) { + + + + + + + + } + +
Week startingCredit (Cr)Debit (Dr)Net for the weekTxns
{{ w.label | date: 'dd MMM yyyy' }}₹{{ w.in | number: '1.0-0' }}₹{{ w.out | number: '1.0-0' }}{{ drcr(w.in - w.out, '1.0-0') }}{{ w.count }}
+ + +
+ @if (trail.summary.largestIn) { +
+ Largest credit (Cr): ₹{{ trail.summary.largestIn.amount | number: '1.2-2' }}, received from {{ trail.summary.largestIn.party }} by {{ trail.summary.largestIn.mode }} on + {{ trail.summary.largestIn.date | date: 'dd MMM yyyy' }}. +
+ } + @if (trail.summary.largestOut) { +
+ Largest debit (Dr): ₹{{ trail.summary.largestOut.amount | number: '1.2-2' }}, paid to {{ trail.summary.largestOut.party }} by {{ trail.summary.largestOut.mode }} on + {{ trail.summary.largestOut.date | date: 'dd MMM yyyy' }}. +
+ } +
+

+ Observations use fixed starting values (for example 3× the declared turnover, a credit mostly paid out again + within 3 days, one party above half of all the money moved). They point at something worth a look; they are + not findings, and the bank's compliance team sets the real thresholds. +

+ } @else { +
+ + + + + + + + + + + + + + + @for (t of trail.transactions; track t.id) { + + + + + + + + + + + } @empty { + + + + } + +
DateCr / DrModeOther sideReferenceCredit (Cr)Debit (Dr)Balance after
{{ t.date | date: 'dd MMM yyyy' }} + + {{ t.direction === 'IN' ? 'Cr · received' : 'Dr · paid out' }} + + + {{ t.mode }} + @if (t.chequeNo) { +
No. {{ t.chequeNo }}
+ } +
+ {{ t.counterpartyAccount || '—' }} +
+ {{ t.counterpartyBank || '' }}{{ t.counterpartyIfsc ? ' · ' + t.counterpartyIfsc : '' }} +
+
+ {{ t.reference || '—' }} +
{{ t.remarks }}
+
{{ t.direction === 'IN' ? '₹' + (t.amount | number: '1.2-2') : '' }}{{ t.direction === 'OUT' ? '₹' + (t.amount | number: '1.2-2') : '' }}{{ drcr(t.balanceAfter) }}
No transactions in this window.
+
+

+ “Mode” and the other side are what the payment recorded: cash, cheque (by its number), and NEFT, RTGS, IMPS, + UPI, demand draft or internal transfer where the payment says so. Where it recorded nothing, it shows as “Not + recorded”. “Balance after” is the account's balance once that transaction was posted: Dr when overdrawn, Cr + when in credit. +

+ } + } +
+
diff --git a/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.scss b/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.scss new file mode 100644 index 000000000..35d0464bb --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.scss @@ -0,0 +1,770 @@ +/** + * 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 { + display: block; + max-width: 100%; + overflow-x: hidden; +} + +.cc-intro { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 12px 16px; + margin-bottom: 14px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: #eff6ff; + color: #1e3a8a; + font-size: 13px; + line-height: 1.5; + + mat-icon { + flex-shrink: 0; + color: #1d4ed8; + } +} + +.cc-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 14px; +} + +.cc-field { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + + select { + padding: 6px 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + font: inherit; + font-size: 13px; + text-transform: none; + letter-spacing: 0; + color: #0f172a; + } +} + +.cc-check { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #334155; +} + +.cc-asof { + font-size: 12px; + color: #64748b; +} + +.cc-refresh { + margin-left: auto; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + cursor: pointer; + padding: 2px 6px; + color: #334155; +} + +.cc-cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.cc-card { + padding: 12px 16px; + border: 1px solid #e2e8f0; + border-left: 4px solid #1a237e; + border-radius: 8px; + background: #fff; + + &--in { + border-left-color: #15803d; + } + + &--out { + border-left-color: #b91c1c; + } + + &__n { + font-size: 20px; + font-weight: 800; + color: #0f172a; + font-variant-numeric: tabular-nums; + } + + &__l { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #64748b; + } +} + +.cc-note { + padding: 14px 16px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + color: #334155; + font-size: 13px; + + &--warn { + border-color: #fcd34d; + background: #fffbeb; + color: #78350f; + } +} + +.cc-table-wrap { + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; +} + +.cc-table { + width: 100%; + border-collapse: collapse; + + th { + padding: 12px 6px; + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + text-align: left; + } + + td { + padding: 10px 6px; + border-top: 1px solid #f1f5f9; + font-size: 13px; + color: #1e293b; + vertical-align: top; + } + + .r { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .in { + color: #15803d; + font-weight: 600; + } + + .out { + color: #b91c1c; + font-weight: 600; + } +} + +.cc-name { + font-weight: 600; +} + +.cc-sub { + margin-top: 1px; + font-size: 11px; + font-weight: 400; + color: #64748b; +} + +.mono { + font-family: ui-monospace, monospace; +} + +.cc-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 999px; + background: #e2e8f0; + color: #475569; + font-size: 10px; + font-weight: 700; + + &--cc { + background: #dbeafe; + color: #1d4ed8; + } +} + +.cc-ratio { + display: inline-block; + padding: 1px 7px; + border-radius: 999px; + background: #f1f5f9; + font-weight: 700; + + &--hi { + background: #fee2e2; + color: #b91c1c; + } +} + +.cc-foot { + margin: 10px 2px 0; + font-size: 12px; + color: #64748b; +} + +.cc-row { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +.cc-trail-btn, +.cc-btn { + padding: 5px 12px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + color: #1e3a8a; + font: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; + + &:hover { + background: #eff6ff; + } +} + +/* The money-trail popup. */ +.cc-dialog { + padding: 20px 28px 24px; + + &__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + } + + &__title { + font-size: 18px; + font-weight: 800; + color: #0f172a; + } + + &__bar { + margin-top: 12px; + text-align: right; + } +} + +.cc-groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 16px; +} + +.cc-group { + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; + + &__title { + padding: 8px 10px; + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + + &--pad { + margin-bottom: 8px; + border-radius: 6px; + } + } +} + +.cc-mini { + width: 100%; + border-collapse: collapse; + + th { + padding: 6px 10px; + color: #64748b; + font-size: 11px; + font-weight: 700; + text-align: left; + } + + td { + padding: 7px 10px; + border-top: 1px solid #f1f5f9; + font-size: 13px; + color: #1e293b; + } + + .r { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .in { + color: #15803d; + font-weight: 600; + } + + .out { + color: #b91c1c; + font-weight: 600; + } +} + +.cc-empty { + padding: 12px; + color: #64748b; + text-align: center; +} + +.cc-dir { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + background: #f1f5f9; + font-size: 11px; + font-weight: 700; + + &--in { + background: #dcfce7; + color: #15803d; + } + + &--out { + background: #fee2e2; + color: #b91c1c; + } +} + +/* The two buttons are stacked so the Details column stays narrow and the table fits the screen. */ +.cc-actions { + width: 84px; + + .cc-trail-btn { + display: block; + width: 100%; + } + + .cc-trail-btn + .cc-trail-btn { + margin-top: 6px; + } +} + +.cc-trail-btn--main { + background: #1d4ed8; + border-color: #1d4ed8; + color: #fff; + + &:hover { + background: #1e40af; + } +} + +.cc-tabs { + display: flex; + gap: 8px; + margin-bottom: 14px; + border-bottom: 1px solid #e2e8f0; +} + +.cc-tab { + padding: 8px 14px; + border: 0; + border-bottom: 3px solid transparent; + background: transparent; + color: #64748b; + font: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; + + &--on { + border-bottom-color: #1d4ed8; + color: #1d4ed8; + } +} + +.cc-kpis { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.cc-kpi { + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-left: 4px solid #1a237e; + border-radius: 8px; + background: #fff; + + &--in { + border-left-color: #15803d; + } + + &--out { + border-left-color: #b91c1c; + } + + &__n { + font-size: 16px; + font-weight: 800; + color: #0f172a; + font-variant-numeric: tabular-nums; + + &.in { + color: #15803d; + } + + &.out { + color: #b91c1c; + } + } + + &__l { + margin-top: 2px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: #64748b; + } +} + +.cc-obs { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.cc-ob { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 14px; + border: 1px solid #e2e8f0; + border-left: 4px solid #94a3b8; + border-radius: 8px; + background: #f8fafc; + + &__lvl { + flex-shrink: 0; + min-width: 58px; + padding: 2px 8px; + border-radius: 999px; + background: #e2e8f0; + color: #475569; + font-size: 10px; + font-weight: 800; + text-align: center; + } + + &__t { + font-size: 13px; + font-weight: 700; + color: #0f172a; + } + + &__d { + margin-top: 1px; + font-size: 12.5px; + line-height: 1.45; + color: #475569; + } + + &--high { + border-left-color: #dc2626; + background: #fef2f2; + + .cc-ob__lvl { + background: #fee2e2; + color: #b91c1c; + } + } + + &--medium { + border-left-color: #f59e0b; + background: #fffbeb; + + .cc-ob__lvl { + background: #fef3c7; + color: #92400e; + } + } + + &--ok { + border-left-color: #16a34a; + background: #f0fdf4; + + .cc-ob__lvl { + background: #dcfce7; + color: #15803d; + } + } +} + +.cc-bar { + display: inline-block; + width: 54px; + height: 6px; + margin-right: 6px; + border-radius: 3px; + background: #e2e8f0; + vertical-align: middle; + overflow: hidden; + + &__fill { + display: block; + height: 100%; + background: #1d4ed8; + } +} + +.cc-weeks { + margin-bottom: 14px; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + +.cc-big { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: #334155; + + .in { + color: #15803d; + } + + .out { + color: #b91c1c; + } +} + +.cc-legend { + display: flex; + flex-wrap: wrap; + gap: 6px 22px; + margin: 0 0 14px; + padding: 9px 14px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + font-size: 12.5px; + color: #475569; + + .in { + color: #15803d; + } + + .out { + color: #b91c1c; + } +} + +.cc-story { + margin-bottom: 16px; + padding: 14px 18px; + border: 1px solid #bfdbfe; + border-left: 4px solid #1d4ed8; + border-radius: 8px; + background: #eff6ff; + color: #1e3a8a; + + &__title { + margin-bottom: 6px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + p { + margin: 0 0 6px; + font-size: 13.5px; + line-height: 1.55; + } + + p:last-child { + margin-bottom: 0; + } +} + +.cc-kpi__t { + margin-bottom: 3px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #334155; +} + +.cc-facts { + display: flex; + flex-wrap: wrap; + gap: 6px 26px; + margin: -4px 0 16px; + font-size: 12.5px; + color: #475569; + + strong { + color: #0f172a; + } +} + +.cc-ob__w { + margin-top: 4px; + font-size: 12px; + line-height: 1.45; + color: #64748b; + font-style: italic; +} + +.cc-hint { + margin: 0; + padding: 6px 10px 8px; + font-size: 11.5px; + color: #64748b; +} + +.cc-rules-cell { + cursor: pointer; + white-space: nowrap; +} + +.cc-hit { + display: inline-block; + padding: 1px 9px; + border-radius: 999px; + background: #dcfce7; + color: #15803d; + font-size: 12px; + font-weight: 800; + + &--on { + background: #fee2e2; + color: #b91c1c; + } +} + +.cc-rulesbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.cc-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 14px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + color: #334155; + font: inherit; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; + + &--on { + border-color: #1d4ed8; + background: #1d4ed8; + color: #fff; + } + + &__n { + padding: 0 7px; + border-radius: 999px; + background: rgb(0 0 0 / 10%); + font-size: 11px; + } +} + +.cc-hint--top { + padding: 2px 2px 10px; +} + +.cc-res { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 800; + white-space: nowrap; + + &--hit { + background: #fee2e2; + color: #b91c1c; + } + + &--ok { + background: #dcfce7; + color: #15803d; + } + + &--na { + background: #e2e8f0; + color: #64748b; + } +} + +.cc-trial { + display: inline-block; + margin-left: 6px; + padding: 0 7px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-size: 10px; + font-weight: 800; +} + +.cc-sub-cell { + font-size: 12.5px; + color: #475569; +} + +/* A negative amount stays on one line: -₹1,00,000.00, not a minus on one line and the amount on the next. */ +.cc-table td.r { + white-space: nowrap; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.ts b/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.ts new file mode 100644 index 000000000..480d08eaf --- /dev/null +++ b/src/app/rbi-circulars/aml-rules/aml-cc-tracking/aml-cc-tracking.component.ts @@ -0,0 +1,223 @@ +/** + * 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, ViewChild, TemplateRef } from '@angular/core'; +import { CommonModule, formatNumber, formatDate } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { AmlService } from '../../../aml/aml.service'; + +/** + * How much came into and went out of each Cash Credit account, against what the customer declared and what the bank + * sanctioned. Read from the posted current-account transactions, so it works without the AML engine being fed them. + */ +@Component({ + selector: 'mifosx-aml-cc-tracking', + templateUrl: './aml-cc-tracking.component.html', + styleUrls: ['./aml-cc-tracking.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatTooltipModule + ] +}) +export class AmlCcTrackingComponent implements OnInit { + private amlService = inject(AmlService); + private dialog = inject(MatDialog); + + /** The money trail of one account, in a popup. */ + @ViewChild('trailDialog') trailDialogTpl!: TemplateRef; + private trailRef: MatDialogRef | null = null; + trail: any = null; + trailLoading = false; + /** What the popup is showing: the analysis, or every transaction. */ + trailView: 'analysis' | 'rules' | 'trail' = 'analysis'; + /** Which of the account's CC rule checks to list: all of them, or only those that hit, did not hit, or were not checked. */ + ruleFilter: 'ALL' | 'HIT' | 'NO_HIT' | 'NOT_CHECKED' = 'ALL'; + trailError = ''; + + readonly windows = [ + { days: 7, label: 'Last 7 days' }, + { days: 30, label: 'Last 30 days' }, + { days: 90, label: 'Last 90 days' } + ]; + days = 30; + + loading = false; + error = ''; + asOf = ''; + from = ''; + accounts: any[] = []; + + /** A credit or debit this many times the declared turnover for the window is flagged for a look. Indicative only. */ + readonly spikeMultiple = 3; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.error = ''; + this.amlService.getCcTracking(this.days).subscribe({ + next: (res) => { + this.loading = false; + this.accounts = res?.accounts || []; + this.asOf = res?.asOf || ''; + this.from = res?.from || ''; + }, + error: (err: any) => { + this.loading = false; + this.accounts = []; + this.error = err?.error?.defaultUserMessage || 'Could not load the CC account figures.'; + } + }); + } + + /** The server returns only Cash Credit accounts. */ + get shown(): any[] { + return this.accounts; + } + + get totalIn(): number { + return this.shown.reduce((sum, a) => sum + (Number(a.credits) || 0), 0); + } + + get totalOut(): number { + return this.shown.reduce((sum, a) => sum + (Number(a.debits) || 0), 0); + } + + /** Opens where one account's money came from and went: each transaction, its mode, and the party on the other side. */ + openTrail(account: any, view: 'analysis' | 'rules' | 'trail' = 'analysis'): void { + this.trailView = view; + this.ruleFilter = view === 'rules' ? 'HIT' : 'ALL'; + this.trail = null; + this.trailError = ''; + this.trailLoading = true; + this.amlService.getCcAccountTrail(account.accountId, this.days).subscribe({ + next: (res) => { + this.trail = res; + this.trailLoading = false; + }, + error: (err: any) => { + this.trailLoading = false; + this.trailError = err?.error?.defaultUserMessage || 'Could not load the money trail for this account.'; + } + }); + this.trailRef?.close(); + this.trailRef = this.dialog.open(this.trailDialogTpl, { + width: '1500px', + maxWidth: '98vw', + height: '94vh', + maxHeight: '96vh', + autoFocus: false + }); + this.trailRef.afterClosed().subscribe(() => (this.trailRef = null)); + } + + /** How many of the open account's CC rule checks have the given result. */ + ruleCount(status: string): number { + return (this.trail?.ruleChecks || []).filter((r: any) => !status || r.status === status).length; + } + + /** The open account's CC rule checks, narrowed to the chosen result. */ + get filteredRuleChecks(): any[] { + const all: any[] = this.trail?.ruleChecks || []; + return this.ruleFilter === 'ALL' ? all : all.filter((r) => r.status === this.ruleFilter); + } + + riskLabel(category: number): string { + return category === 3 ? 'High' : category === 2 ? 'Medium' : category === 1 ? 'Low' : '—'; + } + + closeTrail(): void { + this.trailRef?.close(); + } + + /** + * A balance the way a bank writes it: Dr for a debit balance (on a CC account, the overdraft drawn - the customer owes + * the bank), Cr for a credit balance (the customer's own money), and a bare amount for nil. + */ + drcr(value: number | string | null | undefined, digits = '1.2-2'): string { + if (value === null || value === undefined || value === '') return '—'; + const n = Number(value); + if (n === 0) return `₹${formatNumber(0, 'en-US', digits)}`; + return `₹${formatNumber(Math.abs(n), 'en-US', digits)} ${n < 0 ? 'Dr' : 'Cr'}`; + } + + /** The account's activity told in plain words, from the same figures the cards and tables show. */ + get story(): string[] { + const t = this.trail; + if (!t?.summary) return []; + const s = t.summary; + const day = (iso: string) => formatDate(iso, 'dd MMM yyyy', 'en-US'); + const money = (n: number) => `₹${formatNumber(Math.abs(Number(n)), 'en-US', '1.2-2')}`; + const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`; + const lines: string[] = []; + + lines.push( + `From ${day(t.from)} to ${day(t.asOf)}, this ${t.isCc ? 'Cash Credit' : 'current'} account received ${money(s.in)} (Cr) in ` + + `${plural(s.inCount, 'credit')} and paid out ${money(s.out)} (Dr) in ${plural(s.outCount, 'debit')}.` + ); + const net = Number(s.net); + lines.push( + net < 0 + ? `Debits were ${money(net)} more than credits, so more money went out than came in.` + : net > 0 + ? `Credits were ${money(net)} more than debits, so more money came in than went out.` + : 'Credits and debits were equal.' + ); + const bal = Number(t.balance); + const limit = Number(t.sanctionedLimit) || 0; + if (bal < 0) { + lines.push( + `The account now stands at ${money(bal)} Dr: it is overdrawn, which means the customer has used ${money(bal)} of the ` + + `bank's money${limit > 0 ? `, ${((-bal * 100) / limit).toFixed(1)}% of the ${money(limit)} sanctioned limit` : ''}.` + ); + } else if (bal > 0) { + lines.push( + `The account now stands at ${money(bal)} Cr: the customer's own money is in it and none of the limit is in use.` + ); + } else { + lines.push('The account now stands at nil.'); + } + if (s.lowestBalance != null && Number(s.lowestBalance) < 0) { + lines.push( + `At its lowest it was ${money(s.lowestBalance)} Dr${s.peakLimitUsedPercent != null ? `, which is ${s.peakLimitUsedPercent}% of the limit` : ''}.` + ); + } + const mode = t.byMode?.[0]; + if (mode) { + lines.push(`Most of the money moved by ${mode.label} (${mode.sharePercent}% of everything).`); + } + const party = (t.byCounterparty || []).find((p: any) => p.label !== 'Not recorded'); + if (party) { + lines.push( + `The party handling the most money was ${party.label}${party.sub ? ' (' + party.sub + ')' : ''}: ` + + `${money(party.in)} Cr from it and ${money(party.out)} Dr to it.` + ); + } + return lines; + } + + /** An amount with its sign in front of the rupee sign: -₹1,00,000.00, not ₹-1,00,000.00. */ + rs(value: number | string | null | undefined, digits = '1.2-2'): string { + if (value === null || value === undefined || value === '') return '—'; + const n = Number(value); + return `${n < 0 ? '-' : ''}₹${formatNumber(Math.abs(n), 'en-US', digits)}`; + } + + /** True when a side of the account's activity is well beyond what the customer declared for the window. */ + isSpike(ratio: number | null): boolean { + return ratio != null && ratio >= this.spikeMultiple; + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html index ab2aaf069..6768b5be4 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html @@ -303,153 +303,6 @@ - - - - - -
- @if (isExpanded(alert)) { -
-
- Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — - {{ amount(alert) }} - @if (selectableRulesOf(alert).length > 1) { - - } -
- - @for (rule of matchedRules(alert); track rule.ruleCode) { -
- - @if (isRuleSelectable(rule)) { - - } @else if (!isGradedRule(rule)) { - - - gavel - - } @else { - - {{ - !rule.directlyDecided - ? 'remove_circle_outline' - : rule.disposition === 'ESCALATE' - ? 'report' - : 'verified' - }} - - } - {{ - severityLabel(rule.riskCategory) - }} -
- - {{ rule.ruleCode }} - @if (rule.governing) { - governing - } - - @if (!isGradedRule(rule)) { - - → CTR filing - - } - - @if (rule.disposition) { - - {{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }} - - } - - {{ rule.ruleDescription }} -
-
- } - - - @if (selectedRuleCount > 0) { -
- {{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected - - - - -
- } -
- @if (isHighSeverity(alert)) { - A high-severity rule fired, so this transaction is decided on its own rather than in a batch. - } @else if (!alert.highestSeverity) { - A statutory return rather than a graded suspicion — decided on its own. - } @else { - Every rule here is low or medium, so this transaction can be decided together with others. - } -
-
- } -
- -
- Amount {{ amount(alert) }} @@ -541,9 +394,147 @@ [class.row-expanded]="isExpanded(row)" (click)="toggleExpanded(row)" > - + + +
+ + @if (expandedAlert; as alert) { +
+
+ Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — + {{ amount(alert) }} + @if (selectableRulesOf(alert).length > 1) { + + } +
+ + @for (rule of matchedRules(alert); track rule.ruleCode) { +
+ + @if (isRuleSelectable(rule)) { + + } @else if (!isGradedRule(rule)) { + + + gavel + + } @else { + + {{ + !rule.directlyDecided + ? 'remove_circle_outline' + : rule.disposition === 'ESCALATE' + ? 'report' + : 'verified' + }} + + } + {{ + severityLabel(rule.riskCategory) + }} +
+ + {{ rule.ruleCode }} + @if (rule.governing) { + governing + } + + @if (!isGradedRule(rule)) { + + → CTR filing + + } + + @if (rule.disposition) { + + {{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }} + + } + + {{ rule.ruleDescription }} +
+
+ } + + + @if (selectedRuleCount > 0) { +
+ {{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected + + + + +
+ } +
+ @if (isHighSeverity(alert)) { + A high-severity rule fired, so this transaction is decided on its own rather than in a batch. + } @else if (!alert.highestSeverity) { + A statutory return rather than a graded suspicion — decided on its own. + } @else { + Every rule here is low or medium, so this transaction can be decided together with others. + } +
+
+ } +
+
+ diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss index a11f5bc7c..3e2efe30d 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +:host { + display: block; + max-width: 100%; + overflow-x: hidden; +} + .flagged-toolbar { display: flex; align-items: center; @@ -744,11 +750,13 @@ } .actions-cell { - white-space: nowrap; + white-space: normal; } .action-group { display: inline-flex; + flex-wrap: wrap; + justify-content: center; align-items: center; gap: 2px; } @@ -1011,3 +1019,21 @@ color: #1e293b; font-variant-numeric: tabular-nums; } + +/* The rules popup. Its content is the same markup the inline pane used, so it only needs room and a close button. */ +.rules-dialog { + position: relative; + padding: 8px 0 0; + + .detail-inner { + padding: 18px 24px 22px; + border-bottom: none; + } +} + +.rules-dialog-close { + position: absolute; + top: 4px; + right: 8px; + z-index: 1; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts index 85b49edf4..6efa46158 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core'; +import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild, TemplateRef } from '@angular/core'; import { MatTableModule, MatTableDataSource } from '@angular/material/table'; import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; import { MatIconModule } from '@angular/material/icon'; @@ -167,9 +167,24 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * and the one state the flag existed to make visible — this one is actually being worked — became meaningless. */ toggleExpanded(alert: any): void { - this.expandedId = this.expandedId === alert.id ? null : alert.id; - // Ticks belong to the alert they were made on, not to whichever pane is open next. + this.expandedId = alert.id; + // Ticks belong to the alert they were made on, not to whichever popup is open next. this.selectedRuleCodes.clear(); + this.dialog + .open(this.rulesDialogTpl, { width: '780px', maxWidth: '95vw', maxHeight: '90vh', autoFocus: false }) + .afterClosed() + .subscribe(() => { + this.expandedId = null; + this.selectedRuleCodes.clear(); + }); + } + + /** The popup that lists every rule the open alert tripped. */ + @ViewChild('rulesDialog') rulesDialogTpl!: TemplateRef; + + /** The alert the popup is showing, looked up fresh so a feed refresh behind it does not leave it stale. */ + get expandedAlert(): any { + return this.alerts.data.find((a: any) => a.id === this.expandedId) ?? null; } isExpanded(alert: any): boolean { diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss index 9d62ff9ed..1010c69df 100644 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +:host { + display: block; + max-width: 100%; + overflow-x: hidden; +} + .ra-intro { display: flex; align-items: flex-start; diff --git a/src/app/rbi-circulars/aml-rules/aml-rules.component.html b/src/app/rbi-circulars/aml-rules/aml-rules.component.html index 7747c541c..83a0c844e 100644 --- a/src/app/rbi-circulars/aml-rules/aml-rules.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-rules.component.html @@ -58,6 +58,38 @@
+ +
+ Status + +
+ + +
+ Account + +
+
Applies over @@ -84,7 +116,15 @@ Rule Description - {{ element.ruleName }} + + {{ element.ruleName }} + TRIAL · day {{ trialDays(element) + 1 }} + @@ -163,8 +203,24 @@ + + + Trial + + + Normal + + + - + @@ -203,6 +259,15 @@
+ + + + +
+ +
+
+
diff --git a/src/app/rbi-circulars/aml-rules/aml-rules.component.scss b/src/app/rbi-circulars/aml-rules/aml-rules.component.scss index 2c230ac84..c5229284d 100644 --- a/src/app/rbi-circulars/aml-rules/aml-rules.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-rules.component.scss @@ -78,6 +78,7 @@ .aml-card-content { padding: 24px; + overflow-x: hidden; } /* Rules Master / Flagged Transactions. Padding lives on the body rather than the group so the @@ -86,10 +87,25 @@ ::ng-deep .mat-mdc-tab-body-wrapper { padding-top: 4px; } + + /* Material makes each tab body a horizontal scroller, so the few pixels the tables overshoot by showed up as a + scrollbar under every tab. The tables are width: 100% and their cells wrap, so nothing real sits out there. */ + ::ng-deep .mat-mdc-tab-body-content { + overflow-x: hidden; + } + + ::ng-deep .mat-mdc-tab-body-wrapper, + ::ng-deep .mat-mdc-tab-body { + max-width: 100%; + } } .aml-tab-body { padding-top: 16px; + + /* The tab's own box, in case something inside it still overshoots by a few pixels. */ + overflow-x: hidden; + max-width: 100%; } .aml-table { @@ -426,3 +442,26 @@ font-size: 13px; color: #475569; } + +/* A rule on trial: listed first, tinted, with a badge, until an officer marks it normal. */ +.trial-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 8px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.rule-row--trial { + background: #fffbeb; +} + +.trial-normal { + color: #94a3b8; + font-size: 12px; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-rules.component.ts b/src/app/rbi-circulars/aml-rules/aml-rules.component.ts index f161186c5..1fcf46833 100644 --- a/src/app/rbi-circulars/aml-rules/aml-rules.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-rules.component.ts @@ -28,6 +28,7 @@ import { AlertService } from '../../core/alert/alert.service'; import { AddAmlRuleDialogComponent } from './add-aml-rule-dialog/add-aml-rule-dialog.component'; import { AmlFlaggedTransactionsComponent } from './aml-flagged-transactions/aml-flagged-transactions.component'; import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurring-alerts.component'; +import { AmlCcTrackingComponent } from './aml-cc-tracking/aml-cc-tracking.component'; @Component({ selector: 'mifosx-aml-rules', @@ -49,7 +50,8 @@ import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurrin MatTabsModule, FormsModule, AmlFlaggedTransactionsComponent, - AmlRecurringAlertsComponent + AmlRecurringAlertsComponent, + AmlCcTrackingComponent ] }) export class AmlRulesComponent implements OnInit { @@ -68,7 +70,8 @@ export class AmlRulesComponent implements OnInit { 'appliesOver', 'reason', 'amlCheckStatus', - 'bannedEntityMatch' + 'bannedEntityMatch', + 'trial' ]; /** Every rule as returned by the API, before the period filter is applied. */ @@ -85,6 +88,26 @@ export class AmlRulesComponent implements OnInit { ]; activePeriod = ''; + /** + * Which kind of account the rules are for. CC is a Cash Credit account: a current account allowed to draw an + * overdraft against a sanctioned limit. The chip lists every scenario that watches such an account, whatever its + * coverage status, so the officer sees what is and is not yet being checked. + */ + readonly accountFilters: { key: string; label: string }[] = [ + { key: '', label: 'All accounts' }, + { key: 'CC', label: 'CC' } + ]; + activeAccount = ''; + + /** Whether to list only the rules still on trial. */ + readonly statusFilters: { key: string; label: string }[] = [ + { key: '', label: 'All rules' }, + { key: 'TRIAL', label: 'On trial' } + ]; + activeStatus = ''; + private statusCounts: Record = {}; + private accountCounts: Record = {}; + /** Free-text search, applied on top of the period filter. */ searchTerm = ''; @@ -108,7 +131,15 @@ export class AmlRulesComponent implements OnInit { private loadRules(): void { this.clientsService.getClientRiskRules().subscribe({ next: (data) => { - this.allRules = data || []; + // A rule on trial is listed first, the newest first; the rest keep the catalog order. + const all: any[] = data || []; + const trial = all + .filter((r) => r.onTrial) + .sort((a, b) => String(b.trialStartedOn || '').localeCompare(String(a.trialStartedOn || '')) || b.id - a.id); + this.allRules = [ + ...trial, + ...all.filter((r) => !r.onTrial) + ]; this.applyPeriodFilter(); this.rules.paginator = this.paginator; }, @@ -145,6 +176,48 @@ export class AmlRulesComponent implements OnInit { this.applyPeriodFilter(); } + setStatus(key: string): void { + this.activeStatus = key; + this.applyPeriodFilter(); + } + + /** How many rules are on each status chip — shown on the chips. */ + statusCount(key: string): number { + return this.statusCounts[key] ?? 0; + } + + /** Ends a rule's trial: it stays in the catalog as a normal rule. */ + markNormal(rule: any): void { + this.clientsService.markClientRiskRuleNormal(rule.id).subscribe({ + next: () => { + this.alertService.alert({ type: 'AML Rule', message: `"${rule.ruleName}" is now a normal rule.` }); + this.loadRules(); + } + }); + } + + /** Days a rule has been on trial, from the date it was added. */ + trialDays(rule: any): number { + if (!rule?.trialStartedOn) return 0; + const started = new Date(String(rule.trialStartedOn)); + return Math.max(0, Math.floor((Date.now() - started.getTime()) / 86400000)); + } + + setAccount(key: string): void { + this.activeAccount = key; + this.applyPeriodFilter(); + } + + /** How many rules belong to each account chip — shown on the chips. */ + accountCount(key: string): number { + return this.accountCounts[key] ?? 0; + } + + /** Whether a rule is for the account kind the officer has picked. */ + private inAccountScope(rule: any): boolean { + return !this.activeAccount || (this.activeAccount === 'CC' && !!rule.ccRelevant); + } + applySearch(value: string): void { this.searchTerm = value; this.rules.filter = value.trim().toLowerCase(); @@ -171,9 +244,12 @@ export class AmlRulesComponent implements OnInit { } private applyPeriodFilter(): void { - this.rules.data = this.activePeriod - ? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod) - : this.allRules; + this.rules.data = this.allRules.filter( + (r) => + this.inAccountScope(r) && + (!this.activeStatus || !!r.onTrial) && + (!this.activePeriod || this.periodKey(r) === this.activePeriod) + ); this.recomputePeriodCounts(); if (this.rules.paginator) { this.rules.paginator.firstPage(); @@ -191,7 +267,12 @@ export class AmlRulesComponent implements OnInit { */ private recomputePeriodCounts(): void { const filter = this.rules.filter; - const pool = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules; + const searched = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules; + // The account chips count within the search and the chosen period; the period chips within the chosen account. + const inPeriod = searched.filter((r) => !this.activePeriod || this.periodKey(r) === this.activePeriod); + this.accountCounts = { '': inPeriod.length, CC: inPeriod.filter((r) => !!r.ccRelevant).length }; + this.statusCounts = { '': searched.length, TRIAL: searched.filter((r) => !!r.onTrial).length }; + const pool = searched.filter((r) => this.inAccountScope(r) && (!this.activeStatus || !!r.onTrial)); this.periodCounts = { '': pool.length }; for (const p of this.periodFilters) { if (p.key) { diff --git a/src/app/rbi-circulars/form9-return/form9-bank-format.ts b/src/app/rbi-circulars/form9-return/form9-bank-format.ts new file mode 100644 index 000000000..1a0d24b78 --- /dev/null +++ b/src/app/rbi-circulars/form9-return/form9-bank-format.ts @@ -0,0 +1,480 @@ +/** + * 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 type { Form9Line, Form9Return } from './form9-return.component'; + +/** + * Form IX as the bank's own system prints it (code 982-P): every head of the form in its printed + * order and wording, three dated columns, and figures with no separators. + * + * This is a presentation of the same return the working view shows. Every figure is a head's + * amount from the return, in rupees thousands as that head was rounded, and every total is the + * sum of the rounded lines above it — which is how the printed form adds, and why its totals + * differ by a thousand from a total taken on the exact rupees. + */ +export interface BankRow { + kind: 'heading' | 'line' | 'total' | 'spacer' | 'text'; + label: string; + indent: number; + /** In rupees thousands; null prints blank, as the printed form leaves an empty head. */ + value?: number | null; + /** The same figure repeated on a line of its own beneath, as the form prints a loss. */ + echo?: number | null; + /** Spaced capitals, as the printed form sets its two grand totals. */ + spaced?: boolean; +} + +export interface BankFormat { + rows: BankRow[]; + totalLiabilities: number; + assetsBeforeLoss: number; + balanceOfLoss: number; + totalAssets: number; + /** Assets less liabilities when assets are the larger side; the form prints it as DIFF ADJUSTED. */ + diffAdjusted: number; + finalTotal: number; + /** Liabilities less assets when the assets fall short — nothing on the printed form carries it. */ + shortfall: number; +} + +/** The sub-lines of 2(g) as the form prints them, with the words an account's name is matched on. */ +const FUND_SUBLINES: { label: string; pattern: RegExp | null }[] = [ + { label: '(i) Building Fund.......................', pattern: /BUILDING FUND/i }, + { label: '(II) Gratuity Fund........................', pattern: /GRATUITY/i }, + { label: '(III) Staff Welfare Fund................', pattern: /WELFARE|STAFF/i }, + { label: '(IV) Silver jubiliee Fund..............', pattern: /SILVER/i }, + { label: '(v) Development Fund...............', pattern: /DEVELOPMENT FUND/i }, + { label: '(VI) Provision on overdue interest', pattern: /OVERDUE/i }, + { label: '(vii) Charitable Fund....................', pattern: /CHARITABLE/i }, + { label: '(viii) Any other Fund....................', pattern: null } +]; + +export function buildBankFormat(data: Form9Return, manualLoss: number | null): BankFormat { + const byKey = new Map(); + for (const line of [ + ...data.liabilityLines, + ...data.assetLines + ]) { + byKey.set(line.configKey, line); + } + + /** A head's rounded amount, or 0 where it is unmapped or empty. */ + const n = (key: string): number => byKey.get(key)?.amount ?? 0; + /** What the form prints against a head: blank where there is nothing to print. */ + const v = (key: string): number | null => { + const amount = n(key); + return amount === 0 ? null : amount; + }; + const sum = (...keys: string[]): number => keys.reduce((total, key) => total + n(key), 0); + const shown = (value: number): number | null => (value === 0 ? null : value); + + const rows: BankRow[] = []; + const heading = (label: string, indent = 0) => rows.push({ kind: 'heading', label, indent }); + const line = (label: string, key: string | null, indent = 1, value?: number | null) => + rows.push({ kind: 'line', label, indent, value: value !== undefined ? value : key ? v(key) : null }); + const text = (label: string, indent = 2) => rows.push({ kind: 'text', label, indent }); + const total = (label: string, value: number, spaced = false) => + rows.push({ kind: 'total', label, indent: 0, value: spaced ? value : shown(value), spaced }); + const spacer = () => rows.push({ kind: 'spacer', label: '', indent: 0 }); + const inter = (key: string, ...labels: string[]) => { + // A head the form splits across several printed lines (i-iii, v) carries its figure on the + // first of them, so an amount mapped there still prints. + labels.forEach((label, i) => line(label, key, 2, i === 0 ? v(key) : null)); + }; + + // ── Liabilities ── + heading('1. PAID UP CAPITAL'); + line('(a) individuals', 'FORM9_L1_A_CAPITAL_INDIVIDUALS'); + line('(b) Co-operatives societies', 'FORM9_L1_B_CAPITAL_COOP_SOCIETIES'); + line('(c) Government Co.op. Bank on behalf of the Sta', 'FORM9_L1_C_CAPITAL_STATE_GOVT'); + text('Government', 1); + line('(d) Others', 'FORM9_L1_D_CAPITAL_OTHERS'); + const total1 = sum( + 'FORM9_L1_A_CAPITAL_INDIVIDUALS', + 'FORM9_L1_B_CAPITAL_COOP_SOCIETIES', + 'FORM9_L1_C_CAPITAL_STATE_GOVT', + 'FORM9_L1_D_CAPITAL_OTHERS' + ); + total('Total of 1.......................................', total1); + spacer(); + + heading('2. Reserve fund etc.'); + line('(a) statutory Reserve', 'FORM9_L2_A_STATUTORY_RESERVE'); + line('(b) Agricultural Credit (Stabilisation) Fund', 'FORM9_L2_B_AGRI_CREDIT_FUND'); + line('(c) Dividend Equalisation Fund', 'FORM9_L2_C_DIVIDEND_EQUALISATION'); + line('(d) Special bad debts Reserve', 'FORM9_L2_D_SPECIAL_BAD_DEBT'); + line('(e) Bad and doubtful debts Reserve', 'FORM9_L2_E_BAD_DOUBTFUL_RESERVE'); + line('(f) Investment depreciation Reserve', 'FORM9_L2_F_INVESTMENT_DEPRECIATION'); + heading('(g) Other Funds and Reserve', 1); + + // 2(g) is one head in the return and eight lines on the form. The accounts under it are placed + // on the printed line their name belongs to; whatever matches none lands on "Any other Fund", + // and that line is the head's total less the others — so the eight can never disagree with it. + const funds = byKey.get('FORM9_L2_G_OTHER_FUNDS'); + const fundTotal = funds?.amount ?? 0; + const placed = FUND_SUBLINES.map(() => 0); + const other = FUND_SUBLINES.length - 1; + for (const account of funds?.accounts ?? []) { + if (account.excluded || account.balanceInRupees === null || account.balanceInRupees === undefined) { + continue; + } + const at = FUND_SUBLINES.findIndex((s) => s.pattern && s.pattern.test(account.name)); + placed[at === -1 ? other : at] += account.balanceInRupees; + } + let assigned = 0; + FUND_SUBLINES.forEach((sub, i) => { + let figure: number; + if (i === other) { + figure = fundTotal - assigned; + } else { + figure = Math.round(placed[i] / 1000); + assigned += figure; + } + line(sub.label, null, 2, figure === 0 ? null : figure); + }); + const total2 = sum( + 'FORM9_L2_A_STATUTORY_RESERVE', + 'FORM9_L2_B_AGRI_CREDIT_FUND', + 'FORM9_L2_C_DIVIDEND_EQUALISATION', + 'FORM9_L2_D_SPECIAL_BAD_DEBT', + 'FORM9_L2_E_BAD_DOUBTFUL_RESERVE', + 'FORM9_L2_F_INVESTMENT_DEPRECIATION', + 'FORM9_L2_G_OTHER_FUNDS' + ); + total('Total of 2.......................................', total2); + spacer(); + + heading('3. Principal/Subsidiary state Partnership Fund for'); + text('Share Capital of', 1); + line('(a) Central Co-op. Bank', 'FORM9_L3_A_PARTNERSHIP_CCB'); + line('(b) Primary Agricultural Credit Societies', 'FORM9_L3_B_PARTNERSHIP_PACS'); + line('(c) Other Societies', 'FORM9_L3_C_PARTNERSHIP_OTHER'); + total( + 'Total of 3.......................................', + sum('FORM9_L3_A_PARTNERSHIP_CCB', 'FORM9_L3_B_PARTNERSHIP_PACS', 'FORM9_L3_C_PARTNERSHIP_OTHER') + ); + spacer(); + + heading('4. Demand Liabilities'); + heading('(a) CURRENT DEPOSITS', 1); + line('(i) From Central Co-op. Banks', 'FORM9_L4_A1_CURRENT_CCB', 2); + line('(ii) From Primary Co-op. banks', 'FORM9_L4_A2_CURRENT_PCB', 2); + line('(iii) From Societies', 'FORM9_L4_A3_CURRENT_SOCIETIES', 2); + line('(iv) From Individuals,Firms,Association etc.', 'FORM9_L4_A4_CURRENT_INDIVIDUALS', 2); + line('(v) Reserve fund deposits maintained by', 'FORM9_L4_A5_CURRENT_RESERVE_FUND', 2); + text("societies within the bank's area", 2); + heading('(b) SAVINGS DEPOSITS', 1); + text('(DEMAND LIABILITY PORTION ONLY)', 1); + inter( + 'FORM9_L4_B_SAVINGS_DEMAND_OTHER', + '(i) From Central Co-op. Banks', + '(ii) From Primary Co-op. Banks', + '(iii) From Other Societies' + ); + line('(IV) FROM INDIVIDUALS, FIRMS, ASSOCIATON', 'FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS', 2); + line('(v) Reserve fund deposits maintained by', null, 2); + text("Societies within the bank's area", 3); + heading('(c) BORROWING FROM BANK', 1); + for (const label of [ + '(i) From Reserve Bank of India', + '(ii) From State Bank of India', + '(iii) From Development Bank', + '(iv) From Nationalised Bank', + '(v) From Exim Bank', + '(vi) From National Co-op. Development', + ' Corporation', + '(vii) From State Co-op. Bank', + '(viii) From Central Co-op. Bank' + ]) { + line(label, null, 2); + } + line('(ix) From othes(which may be specified)', 'FORM9_L4_C_BORROWINGS_DEMAND', 2); + line('(D) OTHER DEMAND LIABILITIES', 'FORM9_L4_D_OTHER_DEMAND', 1); + const total4 = sum( + 'FORM9_L4_A1_CURRENT_CCB', + 'FORM9_L4_A2_CURRENT_PCB', + 'FORM9_L4_A3_CURRENT_SOCIETIES', + 'FORM9_L4_A4_CURRENT_INDIVIDUALS', + 'FORM9_L4_A5_CURRENT_RESERVE_FUND', + 'FORM9_L4_B_SAVINGS_DEMAND_OTHER', + 'FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS', + 'FORM9_L4_C_BORROWINGS_DEMAND', + 'FORM9_L4_D_OTHER_DEMAND' + ); + total('Total of 4.......................................', total4); + spacer(); + + heading('5. Time Liabilities'); + heading('(a) FIXED DEPOSITS', 1); + line('(i) From Central Co-op. Banks', 'FORM9_L5_A1_FD_CCB', 2); + line('(ii) From Primary Co-op. Banks', 'FORM9_L5_A2_FD_PCB', 2); + line('(iii) From Other Societies', 'FORM9_L5_A3_FD_SOCIETIES', 2); + line('(iv) From Individuals, Firms, Association etc.', 'FORM9_L5_A4_FD_INDIVIDUALS', 2); + line('(v) Reserve fund deposits maintained by', 'FORM9_L5_A5_FD_RESERVE_FUND', 2); + text("societies within the bank's area", 2); + heading('(b) SAVINGS DEPOSITS', 1); + text('(DEMAND LIABILITY PORTION ONLY)', 1); + inter( + 'FORM9_L5_B_SAVINGS_TIME_OTHER', + '(i) From Central Co-op. Banks', + '(ii) From Primary Co-op. Banks', + '(iii) From other Societies' + ); + line('(IV) FROM INDIVIDUALS,FIRMS,ASSOCIATION ETC.', 'FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS', 2); + line('(v) Reserve fund deposits maintained by', null, 2); + text("societies within the bank's area", 3); + heading('(c) BORROWINGS FROM BANK', 1); + for (const label of [ + '(i) From Reserve Bank of India', + '(ii) From State Bank of India', + '(iii) From Development Bank', + '(iv) From Nationalised Bank', + '(v) From Exim Bank', + '(vi) From National Co-operative Development', + ' Corporation', + '(vii) From State Co-op. Bank', + '(viii) From Central Co-op. Bank' + ]) { + line(label, null, 2); + } + line('(IX) FROM OTHERS (TO BE SPECIFIED)', 'FORM9_L5_C_BORROWINGS_TIME', 2); + line('(d) CASH CERTIFICATES, RECURRING DEPOSITS ETC.', 'FORM9_L5_D_CASH_CERT_RD', 1); + line('(e) OTHER TIME LIABILITIES IF ANY', 'FORM9_L5_E_OTHER_TIME', 1); + const total5 = sum( + 'FORM9_L5_A1_FD_CCB', + 'FORM9_L5_A2_FD_PCB', + 'FORM9_L5_A3_FD_SOCIETIES', + 'FORM9_L5_A4_FD_INDIVIDUALS', + 'FORM9_L5_A5_FD_RESERVE_FUND', + 'FORM9_L5_B_SAVINGS_TIME_OTHER', + 'FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS', + 'FORM9_L5_C_BORROWINGS_TIME', + 'FORM9_L5_D_CASH_CERT_RD', + 'FORM9_L5_E_OTHER_TIME' + ); + total('Total of 5.......................................', total5); + spacer(); + + heading('6. Branch Adjustment'); + const total6 = n('FORM9_L6_BRANCH_ADJUSTMENTS') + n('FORM9_L7_OVERDUE_INTEREST_RESERVE'); + total('Total of 6.......................................', total6); + spacer(); + + const demandAndTime = total4 + total5 + total6; + total('7. Total demand & Time Liabilities(A4+A5+A6)', demandAndTime); + spacer(); + line('8. BALANCE OF PROFIT', 'FORM9_L9_BALANCE_OF_PROFIT', 0); + const totalLiabilities = + total1 + + total2 + + sum('FORM9_L3_A_PARTNERSHIP_CCB', 'FORM9_L3_B_PARTNERSHIP_PACS', 'FORM9_L3_C_PARTNERSHIP_OTHER') + + demandAndTime + + n('FORM9_L9_BALANCE_OF_PROFIT'); + total('TOTAL LIABILITIES', totalLiabilities, true); + + // ── Assets ── + line('1. Cash on hand', 'FORM9_A1_CASH_IN_HAND', 0); + total('TOTAL OF 1', n('FORM9_A1_CASH_IN_HAND')); + heading('2. Balance with Bank (CURRENT)'); + line('(a) With the Reserve Bank of India', 'FORM9_A2_A_CUR_RBI'); + line('(b) With the State Bank of India and its', 'FORM9_A2_B_CUR_SBI'); + text('subsidiaries.', 2); + line('(C) WITH BANKING SYSTEM', 'FORM9_A2_C_CUR_BANKING_SYSTEM'); + line('(d) With the State Co-op. Bank of the State', 'FORM9_A2_D_CUR_STATE_COOP'); + text('Concerned.', 2); + line('(e) With the Central Co-op. Bank of the Dist.', 'FORM9_A2_E_CUR_DCCB'); + text('Concerned.', 2); + line('(f) With Other Co-op. Banks', 'FORM9_A2_F_CUR_OTHER_COOP'); + const currentTotal = sum( + 'FORM9_A2_A_CUR_RBI', + 'FORM9_A2_B_CUR_SBI', + 'FORM9_A2_C_CUR_BANKING_SYSTEM', + 'FORM9_A2_D_CUR_STATE_COOP', + 'FORM9_A2_E_CUR_DCCB', + 'FORM9_A2_F_CUR_OTHER_COOP' + ); + total('Total of 2.(CURRENT)', currentTotal); + heading('2. Balance with Bank (SAVINGS)'); + line('(a) With the Reserve Bank of India', null); + line('(b) With the State Bank of India and its', null); + text('subsidiaries.', 2); + line('(c) With Banking System', 'FORM9_A2_SAV_BANKING_SYSTEM'); + line('(d) With the State Co-op. Bank of the Dist.', null); + text('Concerned.', 2); + line('(e) With the Central Co-op. Bank of the Dist.', null); + text('Concerned.', 2); + line('(f) With Other Co-op. Banks', 'FORM9_A2_SAV_COOP'); + const savingsTotal = sum('FORM9_A2_SAV_BANKING_SYSTEM', 'FORM9_A2_SAV_COOP'); + total('Total of 2.(SAVINGS)', savingsTotal); + // The printed form leaves this combined line empty even where the current total above is filled, + // so it is printed as the form prints it. + total('Total of (CURRENT+SAVINGS)........................', 0); + spacer(); + + heading('3. Money at call & Short notoce.'); + line('(A) WITH THE STATE BANK OF INDIA AND ITS', null); + text('subsidiaries.', 2); + line('(b) With the Banking System', 'FORM9_A3_MONEY_AT_CALL'); + line('(c) With the State Co-op. Bank of', null); + text('the State Concerned.', 2); + line('(d) With the Central Co-op. Bank Of', null); + text('the State Concerned.', 2); + line('(e) With Other Co-op. Banks', null); + total('Total of 3.......................................', n('FORM9_A3_MONEY_AT_CALL')); + spacer(); + + heading('4. Investment'); + heading('(a) In Central and State Government', 1); + line('(i) Central Government', 'FORM9_A4_A1_CENTRAL_GOVT_SEC', 1); + line('(II)STATE GOVERNMENT', 'FORM9_A4_A2_STATE_GOVT_SEC', 1); + line('(b) Debentures of Land Mortgage Banks', 'FORM9_A4_B_LAND_MORTGAGE_DEBENTURES'); + line('(c) Other Trustee securities', 'FORM9_A4_C_OTHER_TRUSTEE_SEC'); + line('(d) Shares of the State Co-op. Banks', 'FORM9_A4_D_SHARES_STATE_COOP'); + line('(e) Shares of the Central Co-op. Banks', 'FORM9_A4_E_SHARES_DCCB'); + line('(f) Shares of other Co-op. Institutions', 'FORM9_A4_F_SHARES_OTHER_COOP'); + heading('(g) Fixed deposits with', 1); + line('(i) The State Bank of India and its subsidiaries', 'FORM9_A4_G1_FD_SBI', 0); + line('(II) THE BANKING SYSTEM(COMPANIES)', 'FORM9_A4_G2_FD_BANKING_SYSTEM', 0); + line('(iii) The State Co-op. Bank of the State', 'FORM9_A4_G3_FD_STATE_COOP', 0); + text('Concerned.', 2); + line('(iv) The Central Co-op. Bank of the district', 'FORM9_A4_G4_FD_DCCB', 0); + text('Concerned.', 2); + line('(v) With other Co-op. Banks', 'FORM9_A4_G5_FD_OTHER_COOP', 0); + line('(H) OTHER INVESTMENTS', 'FORM9_A4_H_OTHER_INVESTMENTS', 1); + const total4a = sum( + 'FORM9_A4_A1_CENTRAL_GOVT_SEC', + 'FORM9_A4_A2_STATE_GOVT_SEC', + 'FORM9_A4_B_LAND_MORTGAGE_DEBENTURES', + 'FORM9_A4_C_OTHER_TRUSTEE_SEC', + 'FORM9_A4_D_SHARES_STATE_COOP', + 'FORM9_A4_E_SHARES_DCCB', + 'FORM9_A4_F_SHARES_OTHER_COOP', + 'FORM9_A4_G1_FD_SBI', + 'FORM9_A4_G2_FD_BANKING_SYSTEM', + 'FORM9_A4_G3_FD_STATE_COOP', + 'FORM9_A4_G4_FD_DCCB', + 'FORM9_A4_G5_FD_OTHER_COOP', + 'FORM9_A4_H_OTHER_INVESTMENTS' + ); + total('Total of 4.......................................', total4a); + spacer(); + + heading('5. Investment out of the principal/subsidiary'); + text('State partnership fund in the Shares of', 1); + line('(a) Central Co-op. Bank', 'FORM9_A5_PARTNERSHIP_INVESTMENTS'); + line('(b) Primary Agricultural Credit Societies', null); + line('(c) Other Societies', null); + total('Total of 5.......................................', n('FORM9_A5_PARTNERSHIP_INVESTMENTS')); + spacer(); + + heading('6. Loan and Advances'); + line('(a) For Seasonal Agricultural Operation', 'FORM9_A6_A_SEASONAL_AGRI'); + line('(b) For Marketing of crops', 'FORM9_A6_B_MARKETING_CROPS'); + line('(i) Secured', null, 2); + line('(ii) Unsecured', null, 2); + line('(c) For Medium-term agricultural purposes', 'FORM9_A6_C_MEDIUM_TERM_AGRI'); + line("(d) For Weaver's Societies", 'FORM9_A6_D_WEAVERS_SOCIETIES'); + heading('(e) For other industrial purposes', 1); + heading('(i) Short term', 2); + line('(ia) Secured', 'FORM9_A6_E_INDUSTRIAL_SHORT', 3); + line('(ib) Unsecured', null, 3); + total('Total of ia+ib ...................................', n('FORM9_A6_E_INDUSTRIAL_SHORT')); + heading('(ii) Medium term', 2); + line('(iia) Secured', 'FORM9_A6_E_INDUSTRIAL_MEDIUM', 3); + line('(iib) Unsecured', null, 3); + total('Total of iia+iib .................................', n('FORM9_A6_E_INDUSTRIAL_MEDIUM')); + heading('(e) For other purposes', 1); + heading('(i) Short term', 2); + line('(ia) Secured', 'FORM9_A6_F_OTHER_SHORT', 3); + line('(ib) Unsecured', null, 3); + total('Total of ia+ib...................................', n('FORM9_A6_F_OTHER_SHORT')); + heading('(ii) Medium term', 2); + line('(iia) Secured', null, 3); + line('(iib) Unsecured', 'FORM9_A6_F_OTHER_MEDIUM', 3); + total('Total of iia+iib..................................', n('FORM9_A6_F_OTHER_MEDIUM')); + heading('(iii) Long term', 2); + line('(iiia) Secured', 'FORM9_A6_F_OTHER_LONG', 3); + line('(iiib) Unsecured', null, 3); + total('Total of iiia+iiib..................................', n('FORM9_A6_F_OTHER_LONG')); + spacer(); + text('Explanation', 0); + text('(i) of the above advances those which are', 1); + text('overdue percentage of overdue to', 2); + text('(a) Demand %', 1); + text('(b) Outstanding %', 1); + text('(ii) (a) Advances outstanding in respect of', 0); + text('priority sector', 3); + text('(b) Their percentage to total loans outstanding %', 0); + const total6a = sum( + 'FORM9_A6_A_SEASONAL_AGRI', + 'FORM9_A6_B_MARKETING_CROPS', + 'FORM9_A6_C_MEDIUM_TERM_AGRI', + 'FORM9_A6_D_WEAVERS_SOCIETIES', + 'FORM9_A6_E_INDUSTRIAL_SHORT', + 'FORM9_A6_E_INDUSTRIAL_MEDIUM', + 'FORM9_A6_F_OTHER_SHORT', + 'FORM9_A6_F_OTHER_MEDIUM', + 'FORM9_A6_F_OTHER_LONG' + ); + total('TOTAL OF 6.......................................', total6a); + spacer(); + + line('8. INTEREST RECEIVABLE (OF WHICH OVERDUE)', 'FORM9_A8_INTEREST_RECEIVABLE', 0); + total('TOTAL OF 8.......................................', n('FORM9_A8_INTEREST_RECEIVABLE')); + spacer(); + line('9. PREMISES, FURNITURE, FIXTURES & OTHER FIXED', 'FORM9_A9_FIXED_ASSETS', 0); + text('assets', 1); + total('TOTAL OF 9 ....................................', n('FORM9_A9_FIXED_ASSETS')); + heading('10. Branch Adjustments'); + heading('11. Other assets'); + line('(I) TANGIBLE ASSETS', 'FORM9_A11_I_TANGIBLE_ASSETS', 1); + line('(ii) Intangible assets', 'FORM9_A11_II_INTANGIBLE_ASSETS', 1); + total( + 'Total of 11......................................', + n('FORM9_A11_I_TANGIBLE_ASSETS') + n('FORM9_A11_II_INTANGIBLE_ASSETS') + ); + spacer(); + + // Item 12. The ledger's own figure where GLs are mapped to it; otherwise the figure the filer + // entered. Nothing in the ledger produces the printed form's loss, so it is never invented here. + const ledgerLoss = n('FORM9_A12_BALANCE_OF_LOSS'); + const balanceOfLoss = ledgerLoss !== 0 ? ledgerLoss : (manualLoss ?? 0); + rows.push({ + kind: 'line', + label: '12. Balance of Loss', + indent: 0, + value: shown(balanceOfLoss), + echo: shown(balanceOfLoss) + }); + + const assetKeys = data.assetLines.map((l) => l.configKey).filter((k) => k !== 'FORM9_A12_BALANCE_OF_LOSS'); + const assetsBeforeLoss = sum(...assetKeys); + const totalAssets = assetsBeforeLoss + balanceOfLoss; + total('T O T A L A S S E T S', totalAssets, true); + + const gap = totalAssets - totalLiabilities; + const diffAdjusted = gap > 0 ? gap : 0; + const shortfall = gap < 0 ? -gap : 0; + if (diffAdjusted !== 0) { + rows.push({ kind: 'line', label: 'DIFF ADJUSTED -------------->', indent: 0, value: diffAdjusted }); + } + // The form prints Bills Purchased after the adjustment, not under item 6. + line('7. Bills Purchased & Discounted %', 'FORM9_A7_BILLS_PURCHASED', 0); + const finalTotal = totalAssets + diffAdjusted; + rows.push({ kind: 'total', label: 'FINAL TOTAL OF ASSEST', indent: 0, value: finalTotal, echo: finalTotal }); + + return { + rows, + totalLiabilities, + assetsBeforeLoss, + balanceOfLoss, + totalAssets, + diffAdjusted, + finalTotal, + shortfall + }; +} diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.html b/src/app/rbi-circulars/form9-return/form9-return.component.html index 98878e3e0..6b1b71352 100644 --- a/src/app/rbi-circulars/form9-return/form9-return.component.html +++ b/src/app/rbi-circulars/form9-return/form9-return.component.html @@ -62,200 +62,276 @@
-
-
-
{{ data.formName }}
-
{{ data.statutoryReference }} · Return code {{ data.returnCode }}
-
As at {{ data.reportingDate }} · {{ data.unit }}
-
+
+ + +
+ @if (view === 'bank' && bank) { + +
+
+ {{ details.address }} + Date : {{ bankDate(data.reportingDate) }} +
+
{{ details.formCode }}   THE BANKING REGULATION FORM IX
+
NAME & DESIG. OF THE OFFICER SUBMITTING THE RETURN
+
+ @if (details.officer || details.designation) { + + {{ details.officer }} + @if (details.officer && details.designation) { + ,   + } + {{ details.designation }} + + } + RS.  IN THOUSAND +
+ + + + + + + + + + + + @for (row of bank.rows; track $index) { + + + + + + + @if (row.echo !== undefined && row.echo !== null) { + + + + + + + } + } + +
{{ bankDate(data.reportingDate) }}{{ bankDate(data.reportingDate) }}{{ bankDate(data.reportingDate) }}
{{ row.label }}{{ bankNum(row.value) }}
{{ bankNum(row.echo) }}
+ +
+ @if (details.bankName) { +
FOR {{ details.bankName }}
+ } +
AUTHORISED  SIGNATORY
+
+
+ User : User Code:{{ userCode }} + Print Date :  {{ printedAt }} +
+
+ } @else { +
+
+
{{ data.formName }}
+
{{ data.statutoryReference }} · Return code {{ data.returnCode }}
+
As at {{ data.reportingDate }} · {{ data.unit }}
+
+ + -
-
- Total Liabilities - {{ amount(data.reconciliation.totalLiabilities) }} +
+
+ 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' + }} + +
-
- 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 }}
  • - } -
-
- } + @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) { + @for (line of data.liabilityLines; track line.configKey) { + + @for (account of line.accounts; track $index) { + + + + + } } - - - + + + - - - + + +
Stated splits and explanations — entering no totalA. Liabilities in India Rs. 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) }}
Percentage of overdues to total loans and advances{{ percent(total('overduePercentage')) }}
Total Demand and Time Liabilities (4 + 5 + 6){{ amount(total('totalDemandAndTime')) }}
Priority sector advances as a percentage of total loans{{ percent(total('prioritySectorPercentage')) }}
TOTAL LIABILITIES{{ amount(total('totalLiabilities')) }}
- } -
-
AUTHORISED SIGNATORY
+ + + + + + + + + + @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 index 3a3fd42e1..854364d92 100644 --- a/src/app/rbi-circulars/form9-return/form9-return.component.scss +++ b/src/app/rbi-circulars/form9-return/form9-return.component.scss @@ -393,3 +393,129 @@ $red: #c5221f; print-color-adjust: exact; } } + +// ── The bank's printed Form IX ── +// Plain on purpose: the point of this view is that it reads like the form the bank files, so it +// carries none of the screen's tinting, badges or rounded cards. +.f9-viewtabs { + display: flex; + gap: 8px; + margin: 0 0 12px; + + &__on { + background: rgb(19 83 160 / 10%); + border-color: $primary; + color: $primary; + } +} + +.bf { + max-width: 900px; + margin: 0 auto; + padding: 18px 22px; + background: #fff; + border: 1px solid $border; + color: #000; + font-family: Arial, Helvetica, sans-serif; + font-size: 13px; + line-height: 1.35; + + &__top { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + } + + // The heading block sits in the middle of the page, as the bank's form sets it. + &__title { + display: block; + margin-bottom: 8px; + font-weight: 700; + text-align: center; + } + + &__officer { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 22px; + } + + &__table { + width: 100%; + border-collapse: collapse; + + th { + padding: 2px 6px; + font-weight: 400; + text-align: right; + } + } + + &__label { + padding-top: 5px; + padding-bottom: 5px; + white-space: pre-wrap; + width: 62%; + } + + &__num { + padding-right: 28px !important; + text-align: right; + white-space: nowrap; + width: 12%; + } + + &__sign { + margin-top: 28px; + } + + &__sign-gap { + margin-top: 60px; + } + + &__foot { + display: flex; + gap: 120px; + margin-top: 48px; + font-size: 12px; + } +} + +.bf-row { + &--total td { + font-weight: 700; + } + + &--spaced .bf__label { + letter-spacing: 3px; + } + + &--spacer td { + height: 14px; + padding: 0; + } + + td { + vertical-align: top; + } +} + +@media print { + .bf { + max-width: none; + padding: 0; + border: 0; + font-size: 12px; + } + + .bf-row { + break-inside: avoid; + } +} + +// Keeps the date at the right edge when the address line is left empty. +.bf__date { + margin-left: auto; +} diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.ts b/src/app/rbi-circulars/form9-return/form9-return.component.ts index 01608f2f9..bf518caac 100644 --- a/src/app/rbi-circulars/form9-return/form9-return.component.ts +++ b/src/app/rbi-circulars/form9-return/form9-return.component.ts @@ -12,6 +12,8 @@ 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 { AuthenticationService } from '../../core/authentication/authentication.service'; +import { BankFormat, buildBankFormat } from './form9-bank-format'; import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook'; /** One account behind a head — a row of the form's "specify" sheets. */ @@ -75,8 +77,33 @@ export interface Form9Return { reconciliation: Form9Reconciliation; caveats: string[]; computable: boolean; + /** The Balance of Loss the bank filed for this date, in rupees thousands, where one is on record. */ + filedBalanceOfLoss?: number; } +/** + * The identity block the bank's printed Form IX carries: the address line, the return's code, the + * officer submitting it and the bank signing. Left empty until the bank's own details are supplied; + * nothing here belongs to any particular bank. + */ +export interface BankReturnDetails { + address: string; + formCode: string; + officer: string; + designation: string; + bankName: string; +} + +// Empty on purpose: the bank's name, address and officer are its own, entered once under Return +// details. Only the form's code, which belongs to the form, starts filled. +const DEFAULT_DETAILS: BankReturnDetails = { + address: '', + formCode: 'CODE NO. 982-P', + officer: '', + designation: '', + bankName: '' +}; + /** * Form IX — the Return on Assets and Liabilities a co-operative bank files monthly, code R408. * @@ -100,6 +127,15 @@ export interface Form9Return { }) export class Form9ReturnComponent { private rbiService = inject(RbiCircularsService); + private authentication = inject(AuthenticationService); + + /** The bank's own printed layout, or the working view with the accounts behind every head. */ + view: 'bank' | 'working' = 'bank'; + details: BankReturnDetails = { ...DEFAULT_DETAILS }; + /** The Balance of Loss on record in Global Configurations for the date, if any. */ + filedLoss: number | null = null; + bank: BankFormat | null = null; + printedAt = ''; data: Form9Return | null = null; loading = false; @@ -148,9 +184,47 @@ export class Form9ReturnComponent { .subscribe((res: Form9Return | null) => { this.loading = false; this.data = res; + // The bank's filed Balance of Loss, sent with the return where one is on record for the date. + this.filedLoss = res?.filedBalanceOfLoss ?? null; + this.printedAt = this.stamp(new Date()); + this.rebuildBank(); }); } + /** Rebuilds the printed layout after the data, or the loss, changes. */ + rebuildBank(): void { + this.bank = this.data ? buildBankFormat(this.data, this.filedLoss) : null; + } + + /** dd/MM/yyyy, as the printed form dates its columns. */ + bankDate(iso: string | null | undefined): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.slice(0, 10).split('-'); + return `${d}/${m}/${y}`; + } + + /** Figures print bare, with no separators, as the bank's form does. */ + bankNum(value: number | null | undefined): string { + return value === null || value === undefined ? '' : String(value); + } + + private stamp(at: Date): string { + const p = (n: number) => String(n).padStart(2, '0'); + const hours = at.getHours(); + const hour12 = hours % 12 === 0 ? 12 : hours % 12; + return `${p(at.getDate())}/${p(at.getMonth() + 1)}/${at.getFullYear()} ${p(hour12)}:${p(at.getMinutes())}:${p(at.getSeconds())}${hours < 12 ? 'AM' : 'PM'}`; + } + + get userCode(): string { + return this.authentication.getCredentials()?.username ?? ''; + } + /** Back to the date prompt, so a filer can run a different month. */ changeDate(): void { this.awaitingDate = true; diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts index 7bffbc36f..0066b46c6 100644 --- a/src/app/rbi-circulars/rbi-circulars.service.ts +++ b/src/app/rbi-circulars/rbi-circulars.service.ts @@ -209,6 +209,45 @@ export class RbiCircularsService { return this.http.get('/rbireservereturn/form1-statutory', { params }); } + /** + * Today's reserve position — what must be held, what is held, and in which accounts. + * + * A different question from the returns above, and the one a treasury desk asks each morning: a + * return made up to a fortnight close cannot say whether the bank is short RIGHT NOW, because the + * balances it reports have since moved. The requirement is still charged on the base date, since + * RBI's lag is a rule rather than an approximation, so the response states both dates. + */ + getReservePosition(onDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (onDate) { + params = params.set('onDate', onDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/position', { params }); + } + + /** + * Moves money between the accounts the cash reserve is held in. + * + * Its own endpoint rather than the manual journal entry screen: that screen is turned off at the + * HTTP edge on this installation, deliberately, so that nobody posts a free-hand entry against + * the ledger. This is not one — both sides must be reserve accounts an administrator has mapped, + * and the amount is checked against what the source actually holds — and the entry it raises goes + * through maker-checker like any other. + */ + transferReserve(transfer: { + fromGlAccountId: number; + toGlAccountId: number; + amount: number; + officeId?: number; + reference?: string; + comments?: string; + }): Observable { + return this.http.post('/rbireservereturn/transfer', transfer); + } + // ── Saved returns ── /** diff --git a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss index 938ff68c2..d46f77316 100644 --- a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss +++ b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss @@ -399,6 +399,8 @@ $red: #c5221f; &__table { font-size: 11.5px; + // Two more columns now that each ratio states its own rate. + min-width: 1100px; th { font-size: 10.5px; @@ -507,3 +509,304 @@ $red: #c5221f; print-color-adjust: exact; } } + +// ─── Part C.XII.c — the securities behind item XII(c) ─── +// Four columns per reported date, so the sheet is wider than the form above it and scrolls on its +// own rather than forcing the whole return into a horizontal scroll. +.f1s-sec { + margin-top: 28px; +} + +.f1s-sec__title { + margin: 0 0 10px; + font-size: 14px; + font-weight: 700; + color: $primary; +} + +.f1s-sec__table { + min-width: 900px; + + th, + td { + padding: 7px 10px; + } +} + +// The date spanning its four value columns. +.f1s-sec__date { + text-align: center; + background: #fff8e1; + border-bottom: 1px solid $gray-200; + font-size: 13px; + letter-spacing: 0.02em; +} + +// A rule down the left of each date's block, so four columns read as one date's working rather +// than as eight columns running together. +.f1s-sec__open { + border-left: 2px solid $gray-500 !important; +} + +// The template numbers its columns 1, 2, 3 and (2-3); carrying the numbers in the heading saves a +// reader working out which column the net value is struck from. +.f1s-sec__no { + display: inline-block; + margin-left: 4px; + padding: 0 4px; + border-radius: 3px; + background: $gray-200; + color: $gray-500; + font-size: 10px; + font-weight: 600; +} + +// "Part - I" and "Part - II" head their own blocks, so they carry more weight than a sub-caption +// inside a part of the form above. +.f1s-sec__part td { + background: $gray-200; + color: $gray-900; + font-size: 12px; + letter-spacing: 0.03em; + text-transform: uppercase; + border-top: 2px solid $gray-500; +} + +// Opening, addition, deduction, closing — named at the left where the eye starts. +.f1s-sec__row { + padding-left: 22px !important; +} + +// The column the requirement is actually settled on, shaded as the RBI template shades it. +.f1s-sec__net { + background: #e8f0fe; + font-weight: 600; +} + +// Depreciation is structurally nil here, not merely zero this period: a dash says the column does +// not apply, where a 0 would read as a figure the bank computed and found to be nothing. +.f1s-sec__nil { + color: $gray-500; +} + +// The grand total of both parts — the figure item XII(c) reports, so it is set apart from the two +// closing balances that make it up. +.f1s-sec__grand td { + background: #d7e3fc; + border-top: 2px solid $primary; +} + +// ─── Daily maintenance, laid out as the CIMS template lays it out ─── +.f1s-daily__sr { + width: 48px; + text-align: center; + color: $gray-500; + font-variant-numeric: tabular-nums; +} + +.f1s-daily__date { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +// The ratio each block of four columns belongs to. +.f1s-daily__group { + text-align: center; + background: #eaf1fb; + border-bottom: 1px solid $gray-200; + letter-spacing: 0.02em; +} + +// The parenthetical under a heading — how the column is struck, not part of its name. +.f1s-daily__note { + display: block; + margin-top: 2px; + font-size: 10px; + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: $gray-500; +} + +// Deficit and surplus carry the template's own shading: a day that fell short is the thing the +// sheet exists to surface, so it is red wherever it appears rather than only on the row. +.f1s-daily__deficit { + background: #fdecea; + color: $red; + font-weight: 600; +} + +.f1s-daily__surplus { + background: #e6f4ea; + color: $green; +} + +// A dash where there is no deficit or no surplus: the column does not apply that day, which a 0 +// would state as a figure computed and found to be nothing. +.f1s-daily__deficit, +.f1s-daily__surplus { + font-variant-numeric: tabular-nums; +} + +// ─── A wide sheet scrolls within itself ─── +// The securities working and the daily sheet both carry more columns than a narrow window holds. +// Without this they were cut off at the window's edge with no way to reach the right-hand columns: +// the page itself does not scroll sideways, so the figures were simply unreachable. +.f1s-scroll { + overflow-x: auto; + // Room for the scrollbar, so it does not sit on top of the last row's figures. + padding-bottom: 4px; + // A scrollbar that is visible without hovering — on a sheet that is cut off, the thing a reader + // needs first is to know there is more to the right. + scrollbar-width: thin; + + &::-webkit-scrollbar { + height: 10px; + } + + &::-webkit-scrollbar-track { + background: $gray-50; + border-radius: 5px; + } + + &::-webkit-scrollbar-thumb { + background: $gray-500; + border-radius: 5px; + + &:hover { + background: $gray-900; + } + } + + // The particulars column stays put while the figures scroll past it, so a row can still be read + // for what it is once the sheet has been scrolled away from its left edge. + .f1s-table__particulars, + .f1s-daily__sr { + position: sticky; + left: 0; + z-index: 1; + background: #fff; + } + + .f1s-daily__sr { + left: 0; + } + + .f1s-daily__date { + position: sticky; + left: 48px; + z-index: 1; + background: #fff; + } + + // A sticky cell on a shaded row keeps that row's shading rather than showing white through it. + .f1s-heading .f1s-table__particulars, + .f1s-sec__part .f1s-table__particulars { + background: $gray-200; + } + + .f1s-total .f1s-table__particulars, + .f1s-total .f1s-daily__sr, + .f1s-total .f1s-daily__date { + background: #e8f0fe; + } + + .f1s-sec__grand .f1s-table__particulars { + background: #d7e3fc; + } + + thead .f1s-table__particulars, + thead .f1s-daily__sr { + background: $gray-50; + z-index: 2; + } +} + +// The rate each day's requirement was struck at. +.f1s-daily__rate { + white-space: nowrap; + color: $gray-500; + font-variant-numeric: tabular-nums; +} + +// The day a rate came into force. Marked because on thirty rows of the same percentage this is the +// one row that matters: the requirement steps here, and the days either side are charged +// differently. +.f1s-daily__rate--new { + background: #fff4e5; + color: $amber; + font-weight: 700; + + &::after { + content: ' ▲'; + font-size: 9px; + } +} + +// The popup shown while the pointer rests on a figure on the return. +.f1s-has-tip { + cursor: help; + + &:hover { + background: rgb(25 118 210 / 8%); + } +} + +.f1s-tip { + position: fixed; + z-index: 2000; + width: 460px; + max-width: calc(100vw - 16px); + padding: 10px 12px; + background: #fff; + border: 1px solid #c5ccd6; + border-radius: 6px; + box-shadow: 0 6px 24px rgb(0 0 0 / 18%); + font-size: 12px; + color: #1f2937; + pointer-events: none; + + &__title { + font-weight: 600; + margin-bottom: 2px; + } + + &__sub { + color: #6b7280; + margin-bottom: 6px; + } + + &__table { + width: 100%; + border-collapse: collapse; + + th, + td { + padding: 3px 4px; + border-top: 1px solid #e5e7eb; + text-align: left; + vertical-align: top; + } + + th { + color: #6b7280; + font-weight: 500; + } + } + + &__num { + text-align: right !important; + white-space: nowrap; + } + + &__out { + color: #9ca3af; + } + + &__formula { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid #e5e7eb; + color: #374151; + } +} diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts index cf3c439e1..7ec31c641 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts +++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts @@ -85,6 +85,31 @@ export interface Form1Row { values?: number[]; /** On the base-date row, the date each column's liabilities were read at. */ baseDates?: string[]; + /** Per column, the GL accounts the figure was made of. Absent on a return the backend has not stated them for. */ + sources?: Form1Source[][]; + /** How a figure with no account of its own is worked out, in words. */ + formula?: string; +} + +/** One GL account behind a printed figure: its own balance, the share taken, and the result. */ +export interface Form1Source { + glCode: string; + name: string; + accountBalance?: number | null; + weightPercentage?: number | null; + amount?: number | null; + excluded?: 'disabled' | 'header' | null; +} + +/** The popup shown while the pointer rests on a figure. */ +export interface Form1Tip { + x: number; + y: number; + label: string; + column: string; + value: number; + sources: Form1Source[]; + formula?: string; } export interface Form1Part { @@ -116,6 +141,20 @@ export interface Form1DailyRow { * no figure, and a zero would read as a day with no liabilities rather than as one not recorded. */ ownLiabilities?: number; + /** + * The rates each requirement was struck at that day, and the dates they came into force. + * + * Per day rather than once for the sheet because rates are effective-dated: a rate changed + * mid-period leaves two requirements on one return, and a reader checking a day's figure against + * a single rate quoted at the top would find arithmetic that fails on one side of the change. + * + * Optional throughout — a return saved before the backend stated them carries no rate, and the + * screen shows nothing rather than claiming a rate the snapshot never recorded. + */ + crrPercentage?: number | null; + crrEffectiveFrom?: string | null; + slrPercentage?: number | null; + slrEffectiveFrom?: string | null; crrRequired: number; crrMaintained: number; /** Negative where the day fell short. */ @@ -172,9 +211,33 @@ export interface Form1Statutory { */ partB?: Form1Part; partC: Form1Part; + /** + * The working behind item XII(c) — what the SLR securities opened at, what moved, what they + * closed at. Absent on a return saved before the sheet existed, so the screen reads it + * defensively rather than assuming every snapshot carries it. + */ + securities?: Form1Securities; unmetRequirements: string[]; } +/** Part C.XII.c — the SLR securities, and how they moved across each fortnight reported. */ +export interface Form1Securities { + title: string; + sections: Form1SecuritySection[]; + /** Both parts together, which is the figure item XII(c) reports. */ + closing: number[]; +} + +/** One part of the securities sheet, with one figure per column reported. */ +export interface Form1SecuritySection { + title: string; + configKey: string; + opening: number[]; + additions: number[]; + deductions: number[]; + closing: number[]; +} + /** One row of the Memorandum or an Annexure — a stated figure, entering no total. */ export interface SectionRow { label: string; @@ -963,6 +1026,93 @@ export abstract class ReserveReturnBase { return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } + /** + * A Form 1 figure, in thousands of rupees - the unit the filed return is printed in. + * + * RBI's own form carries its columns in Rs .000, so a balance of 70,10,45,061.62 is filed + * as 7,01,045. Reporting the rupees here instead would have the screen and the filed + * return disagree on every line, so the conversion belongs with the form, not the reader. + */ + /** + * Whether this is the day a ratio's rate came into force. + * + * The sheet marks that day rather than leaving a reader to compare thirty rows of the same + * percentage: it is where the requirement steps, and every day after it is charged differently + * from every day before. A rate in force since before the period began is not marked — it did not + * change here, and marking it would point at a day where nothing happened. + */ + rateChangedOn(day: Form1DailyRow, ratio: 'crr' | 'slr'): boolean { + const effective = ratio === 'crr' ? day.crrEffectiveFrom : day.slrEffectiveFrom; + return !!effective && effective === day.date; + } + + /** + * A securities section as the sheet prints it — opening, what moved, and the closing balance. + * + * The movement rows are built here rather than in the template because the sheet names them in + * the order a holding actually changes, and a template walking the object's own keys would print + * whatever order the server happened to serialise. + */ + securityRows(section: Form1SecuritySection): { label: string; values: number[]; closing: boolean }[] { + return [ + { label: 'Opening Balance', values: section.opening ?? [], closing: false }, + { label: 'Addition during the fortnight', values: section.additions ?? [], closing: false }, + { label: 'Deduction during the fortnight', values: section.deductions ?? [], closing: false }, + { label: 'Closing Balance', values: section.closing ?? [], closing: true } + ]; + } + + /** The popup, or null while the pointer is off a figure. */ + tip: Form1Tip | null = null; + + /** + * Shows where a figure on the return came from. Placed beside the pointer and kept inside the + * window, so a figure near the right edge opens its popup to the left instead of off screen. + */ + showTip(event: MouseEvent, line: Form1Row, col: number, column: string, value: number): void { + const sources = line.sources?.[col] ?? []; + if (!sources.length && !line.formula) { + this.tip = null; + return; + } + this.tip = { x: 0, y: 0, label: line.label, column, value, sources, formula: line.formula }; + this.moveTip(event); + } + + moveTip(event: MouseEvent): void { + if (!this.tip) { + return; + } + const width = 460; + const height = 60 + this.tip.sources.length * 24 + (this.tip.formula ? 40 : 0); + const gap = 16; + const left = event.clientX + gap + width > window.innerWidth ? event.clientX - gap - width : event.clientX + gap; + const top = event.clientY + gap + height > window.innerHeight ? event.clientY - gap - height : event.clientY + gap; + this.tip = { ...this.tip, x: Math.max(8, left), y: Math.max(8, top) }; + } + + hideTip(): void { + this.tip = null; + } + + /** Rupees with paise, for the popup: the form prints thousands, a reviewer checks against the ledger. */ + rupees(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + thousands(value: number | null | undefined): string { + if (value === null || value === undefined) { + return ''; + } + // A head that rounds to nothing is left blank, as the filed form leaves it. Printing a + // zero would state a reported nil where the bank reports no figure at all. + const inThousands = Math.round(value / 1000); + return inThousands === 0 ? '' : inThousands.toLocaleString('en-IN'); + } + percent(value: number | null | undefined): string { if (value === null || value === undefined) { return '—'; @@ -1405,6 +1555,22 @@ export abstract class ReserveReturnBase { } /** Part A — the liabilities and assets, Roman-numbered I through VIII, across the dated columns. */ + /** + * A Form 1 line's figures as the filed return carries them - in thousands of rupees. + * + * A blank stays blank: a line the form leaves empty must not become a zero on the sheet, + * which would read as a reported nil rather than a head that does not apply. + */ + private cimsFigures(values: (number | null)[] | undefined): (number | null)[] { + return (values ?? []).map((v) => { + if (v === null || v === undefined) { + return null; + } + const inThousands = Math.round(v / 1000); + return inThousands === 0 ? null : inThousands; + }); + } + private addCimsPartA(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Part A'); sheet.columns = [ @@ -1421,7 +1587,7 @@ export abstract class ReserveReturnBase { // would lose which line sits under which head. const row = sheet.addRow([ `${' '.repeat(line.indent ?? 0)}${line.label}`, - ...(line.values ?? []) + ...this.cimsFigures(line.values) ]); row.getCell(1).alignment = { wrapText: true }; if (line.heading || line.total) { @@ -1655,14 +1821,20 @@ export abstract class ReserveReturnBase { 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. + // The movement the ledger actually shows: what each holding opened at, what was bought and + // sold inside the fortnight, and what it closed at. Read off the journal by the server rather + // than stated here, so the closing balance is arithmetic a reader can follow. + // + // Depreciation is nil and face value equals book value because a securities GL holds one + // figure, already net of any provision made against it — a depreciation column drawn off the + // investment reserve would deduct it twice, since that reserve is not held security by + // security. + const sections = this.statutory?.securities?.sections ?? []; const held = this.cimsPartCLine('(c) Unencumbered') ?? dates.map(() => 0); - const movement = (label: string, carries: boolean, closing = false) => { + const movement = (label: string, amounts: number[] | null, closing = false) => { const cells: (string | number)[] = [label]; for (let i = 0; i < dates.length; i++) { - const value = carries ? (held[i] ?? 0) : 0; + const value = amounts?.[i] ?? 0; cells.push(value, value, 0, value); } const row = sheet.addRow(cells); @@ -1671,7 +1843,9 @@ export abstract class ReserveReturnBase { row.font = { bold: true }; } for (let c = 2; c <= dates.length * perDate + 1; c++) { - row.getCell(c).numFmt = '#,##0.00'; + // Thousands, as the filed form prints them: a paise column on a return made up in + // Rs '000 would be reporting a precision the form does not carry. + row.getCell(c).numFmt = '#,##0'; 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. @@ -1682,17 +1856,24 @@ export abstract class ReserveReturnBase { } }; + // Part I is the government securities, Part II everything else counted toward the requirement. + // Where the server has not sent the working — an older saved return — the closing balance still + // prints from Part C's own line, so the sheet totals to the figure the compliance sheet claimed + // rather than coming out empty. + const partOne = sections[0] ?? null; + const partTwo = sections[1] ?? null; + 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); + movement('Opening Balance', partOne?.opening ?? null); + movement('Addition during the fortnights', partOne?.additions ?? null); + movement('Deduction during the fortnights', partOne?.deductions ?? null); + movement('Closing Balance (a)', partOne?.closing ?? held, 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); + movement('Opening Balance', partTwo?.opening ?? null); + movement('Addition during the fortnights', partTwo?.additions ?? null); + movement('Deduction during the fortnights', partTwo?.deductions ?? null); + movement('Closing Balance (b)', partTwo?.closing ?? null, true); + movement('Closing Balance (a+b)', this.statutory?.securities?.closing ?? held, true); boxAllCells(sheet, start, sheet.rowCount, dates.length * perDate + 1); } @@ -1773,16 +1954,19 @@ export abstract class ReserveReturnBase { const required = isCrr ? day.crrRequired : day.slrRequired; const held = isCrr ? day.crrMaintained : day.slrMaintained; const gap = isCrr ? day.crrSurplus : day.slrSurplus; + // Thousands, as on the screen's day-by-day table and the Part A to C sheets of this same + // workbook. This sheet alone was left in whole rupees. + const thousands = (v: number): number => Math.round(v / 1000); 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, + day.baseLiabilities === undefined ? '' : thousands(day.baseLiabilities), + thousands(required), + thousands(held), + gap < 0 ? thousands(-gap) : 0, + gap > 0 ? thousands(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 @@ -1790,7 +1974,7 @@ export abstract class ReserveReturnBase { this.cimsDailyRemark(day, gap) ]); for (let c = 3; c <= 8; c++) { - row.getCell(c).numFmt = '#,##0.00'; + row.getCell(c).numFmt = '#,##0'; row.getCell(c).alignment = { horizontal: 'right' }; } // A day short is the thing this sheet exists to show, and in a column of near-identical @@ -1810,7 +1994,8 @@ export abstract class ReserveReturnBase { 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)}`); + // In thousands, the unit of every other figure on the sheet. + parts.push(`NDTL on the day ${Math.round(day.ownLiabilities / 1000).toLocaleString('en-IN')} (Rs '000)`); } if (gap < 0) { parts.push('SHORT'); @@ -1918,9 +2103,16 @@ export abstract class ReserveReturnBase { * that part at all. */ private addCimsFigures(sheet: ExcelJS.Worksheet, label: string, values?: number[], bold = false): void { + // In thousands, like Part A above it. Only Part A's lines were converted when the form went to + // thousands, so on this sheet Part A read 7,01,045 while IX to XII beneath it read the same + // amounts in whole rupees — two units on one return, and nothing on the sheet to say so. + // A zero stays a zero here, unlike Part A: a requirement or a held amount of nil is a real + // answer on these lines, not a head the bank does not use. const row = sheet.addRow([ label, - ...(values ?? this.cimsDates.map((): null => null)) + ...(values === undefined + ? this.cimsDates.map((): null => null) + : values.map((v) => (v === null || v === undefined ? null : Math.round(v / 1000)))) ]); row.getCell(1).alignment = { wrapText: true }; if (bold) { @@ -1932,7 +2124,9 @@ export abstract class ReserveReturnBase { private formatCimsFigures(row: ExcelJS.Row, from = 2): void { for (let c = from; c < from + this.cimsDates.length; c++) { - row.getCell(c).numFmt = '#,##0.00'; + // Thousands, as the filed form prints them: a paise column on a return made up in Rs '000 + // would report a precision the form does not carry. + row.getCell(c).numFmt = '#,##0'; row.getCell(c).alignment = { horizontal: 'right' }; } } diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html index 10555ff73..6df4d239a 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.html +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -712,7 +712,17 @@ } @for (v of line.values; track $index) { - {{ amount(v) }} + + + {{ thousands(v) }} + } + @if (statutory.securities?.sections?.length) { +
+

{{ statutory.securities!.title }}

+
+ + + + + @for (c of statutory.columns; track $index) { + + } + + + @for (c of statutory.columns; track $index) { + + + + + } + + + + @for (section of statutory.securities!.sections; track section.configKey) { + + + + @for (row of securityRows(section); track row.label) { + + + @for (v of row.values; track $index) { + + + + + } + + } + } + + + @for (v of statutory.securities!.closing; track $index) { + + + + + } + + +
Particulars{{ c }}
Face Value 1Book Value 2Depreciation 3Net Value for SLR 2-3
{{ section.title }}
{{ row.label }}{{ thousands(v) || '–' }}{{ thousands(v) || '–' }}–{{ thousands(v) || '–' }}
Closing Balance (a+b){{ thousands(v) || '–' }}{{ thousands(v) || '–' }}–{{ thousands(v) || '–' }}
+
+
+ } + - Net demand & time liabilities - Section 18 — CRR - Section 24 — SLR - - - On the day - Charged on - Base amount - Required - Maintained - Surplus / (short) - Required - Maintained - Surplus / (short) - - - - @for (day of statutory.daily!.days; track day.date) { - - {{ 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) }} + +
+ + + + + + + + - } - -
Sr. No.Date + Net Demand and Time Liabilities
+ (as on second preceding fortnight) +
Cash Reserve Ratio AmountStatutory Liquidity Ratio Amount
+ + Rate + Required to be maintained + Actually maintained + + Deficit
(Required−Actual) + + + Surplus
(Actual−Required) + + Rate + Required to be maintained + Actually maintained + + Deficit
(Required−Actual) + + + Surplus
(Actual−Required) + + + + + @for (day of statutory.daily!.days; track day.date) { + + {{ $index + 1 }} + {{ day.date }} + + + {{ day.baseLiabilities === undefined ? '—' : thousands(day.baseLiabilities) }} + + + + {{ day.crrPercentage == null ? '—' : percent(day.crrPercentage) }} + + {{ thousands(day.crrRequired) }} + {{ thousands(day.crrMaintained) }} + + {{ day.crrSurplus < 0 ? thousands(-day.crrSurplus) : '–' }} + + + {{ day.crrSurplus > 0 ? thousands(day.crrSurplus) : '–' }} + + + {{ day.slrPercentage == null ? '—' : percent(day.slrPercentage) }} + + {{ thousands(day.slrRequired) }} + {{ thousands(day.slrMaintained) }} + + {{ day.slrSurplus < 0 ? thousands(-day.slrSurplus) : '–' }} + + + {{ day.slrSurplus > 0 ? thousands(day.slrSurplus) : '–' }} + + + } + + +
} } @@ -994,7 +1146,7 @@ {{ line.label }} @for (value of line.values || []; track $index) { - {{ amount(value) }} + {{ thousands(value) }} } } diff --git a/src/app/savings/create-savings-account/create-savings-account.component.ts b/src/app/savings/create-savings-account/create-savings-account.component.ts index 525c98f2b..561766304 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.ts +++ b/src/app/savings/create-savings-account/create-savings-account.component.ts @@ -172,6 +172,11 @@ export class CreateSavingsAccountComponent { if (this.savingsAccountTemplate.clientId) { savingsAccount.clientId = this.savingsAccountTemplate.clientId; + // Set by "Open account in this branch" for a customer of another branch: the branch this account is held at. + const accountOfficeId = this.route.snapshot.queryParamMap.get('accountOfficeId'); + if (accountOfficeId) { + savingsAccount.accountOfficeId = Number(accountOfficeId); + } } else { savingsAccount.groupId = this.savingsAccountTemplate.groupId; } diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html index 4fecc1971..64590ad4b 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html @@ -26,6 +26,62 @@
+ + @if (interBranch) { +
+
+
+
+ Inter-branch transfer + {{ interBranch.voucherNo }} +
+
+ {{ branchesOf(interBranch.from) }} + → + {{ branchesOf(interBranch.to) }} + {{ interBranch.amount | formatNumber }} +
+
+ This is the {{ thisBranchPart?.branchName }} branch's part of the transfer. Each branch + posts its own part, and the two are joined through Liability Transfer Suspense (200901), so they cancel out + across the bank. +
+
+ @for (leg of interBranch.from; track leg.accountNo) { + From {{ leg.accountNo }} · {{ leg.accountName }} ({{ leg.branchName }}) + } + @for (leg of interBranch.to; track leg.accountNo) { + To {{ leg.accountNo }} · {{ leg.accountName }} ({{ leg.branchName }}) + } +
+ @if (interBranch.raisedBy) { +
+ Raised by {{ interBranch.raisedBy }} on {{ interBranch.raisedAt | datetimeFormat }}, + approved by {{ interBranch.approvedBy }} on + {{ interBranch.approvedAt | datetimeFormat }}. +
+ } +
+ @for (part of otherBranchParts; track part.transactionId) { + The {{ part.branchName }} part: + + } +
+
+ + Do not revert one branch's part on its own: the other branch's part would stay and the books would not + balance. +
+
+
+ } + @if (isViewTransaction()) { @if (dataSource) { diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss index 1f216f178..33038d49f 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss @@ -515,3 +515,95 @@ $shadow: 0 2px 12px rgb(0 0 0 / 8%); text-overflow: ellipsis; vertical-align: middle; } + +/* One branch's part of a transfer between branches. */ +.txn-interbranch { + display: flex; + align-items: flex-start; + gap: 14px; + margin: 0 0 16px; + padding: 14px 18px; + border: 1px solid #fcd34d; + border-left: 4px solid #f59e0b; + border-radius: 10px; + background: #fffbeb; + color: #78350f; + + &__icon { + flex-shrink: 0; + margin-top: 2px; + font-size: 18px; + color: #d97706; + } + + &__body { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + } + + &__title { + font-size: 13px; + font-weight: 800; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + &__voucher { + margin-left: 8px; + padding: 1px 8px; + border-radius: 10px; + background: #fde68a; + font-size: 11px; + letter-spacing: 0.02em; + } + + &__route { + font-size: 17px; + } + + &__arrow { + margin: 0 8px; + color: #d97706; + } + + &__amount { + margin-left: 12px; + padding: 1px 10px; + border-radius: 10px; + background: #fde68a; + font-size: 14px; + font-weight: 700; + } + + &__text { + font-size: 13px; + line-height: 1.5; + } + + &__legs { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; + } + + &__link { + padding: 0; + border: 0; + background: none; + color: #b45309; + font-family: monospace; + font-size: 13px; + font-weight: 700; + text-decoration: underline; + cursor: pointer; + } + + &__warn { + font-size: 12.5px; + font-weight: 600; + color: #b91c1c; + } +} diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts index 2b3f5c753..49ae853c2 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts @@ -9,7 +9,8 @@ import { Component, OnInit, ViewChild, inject } from '@angular/core'; import { ViewJournalEntryComponent } from '../view-journal-entry/view-journal-entry.component'; import { MatDialog } from '@angular/material/dialog'; -import { ActivatedRoute } from '@angular/router'; +import { AccountingService } from '../../../accounting/accounting.service'; +import { ActivatedRoute, Router } from '@angular/router'; import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatPaginator } from '@angular/material/paginator'; import { @@ -26,6 +27,8 @@ import { MatRow } from '@angular/material/table'; import { Location, NgClass } from '@angular/common'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { DatetimeFormatPipe } from '../../../pipes/datetime-format.pipe'; @@ -64,6 +67,8 @@ import { MatTooltipModule } from '@angular/material/tooltip'; }) export class ViewJournalEntryTransactionComponent implements OnInit { private route = inject(ActivatedRoute); + private router = inject(Router); + private accountingService = inject(AccountingService); dialog = inject(MatDialog); private location = inject(Location); @@ -90,6 +95,8 @@ export class ViewJournalEntryTransactionComponent implements OnInit { isJournalEntryLoaded = false; isManualJournalEntry = false; + /** Set when this ledger transaction is one branch's part of a transfer between branches (the whole transfer, both branches). */ + interBranch: any = null; ngOnInit() { this.route.data.subscribe((data: { title: string; transaction: any; transferJournalEntryData: any }) => { @@ -101,6 +108,7 @@ export class ViewJournalEntryTransactionComponent implements OnInit { this.isJournalEntryLoaded = true; this.transactionId = data.transaction.pageItems[0].transactionId; this.isManualJournalEntry = data.transaction.pageItems[0].manualEntry; + this.loadInterBranchTransfer(); } } else if (this.isViewTransfer()) { this.journalEntriesData = data.transferJournalEntryData.journalEntryData.content; @@ -110,6 +118,36 @@ export class ViewJournalEntryTransactionComponent implements OnInit { }); } + private loadInterBranchTransfer(): void { + this.interBranch = null; + this.accountingService + .getInterBranchTransfer(this.transactionId) + .pipe(catchError(() => of(null))) + .subscribe((transfer: any) => (this.interBranch = transfer?.interBranch ? transfer : null)); + } + + /** This transaction's own branch part of the inter-branch transfer. */ + get thisBranchPart(): any { + return this.interBranch?.parts?.find((part: any) => part.transactionId === this.transactionId) ?? null; + } + + /** The other branches' parts of the same transfer, each its own ledger transaction. */ + get otherBranchParts(): any[] { + return (this.interBranch?.parts ?? []).filter((part: any) => part.transactionId !== this.transactionId); + } + + /** Distinct branch names among the accounts on one side of the transfer. */ + branchesOf(legs: any[]): string { + return [...new Set((legs ?? []).map((leg: any) => leg.branchName).filter((name: string) => !!name))].join(', '); + } + + openTransaction(transactionId: string): void { + this.router.navigate([ + '/accounting/journal-entries/transactions/view', + transactionId + ]); + } + isViewTransaction(): boolean { return this.title === 'View Transaction'; } 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 debedb2fe..644c6e4bf 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 @@ -67,9 +67,11 @@ import { SponsorDDService } from '../sponsor-dd.service'; DD Charges / Commission Income GL * - {{ acc.glCode }} · {{ acc.name }} + + {{ acc.glCode }} · {{ acc.name }} + - Credited for DD Issuance Charges/Commission + Income accounts only — credited for DD Issuance Charges/Commission Required @@ -77,9 +79,12 @@ import { SponsorDDService } from '../sponsor-dd.service'; GST Payable on Charges GL * - {{ acc.glCode }} · {{ acc.name }} + + {{ acc.glCode }} · {{ acc.name }} + - Credited for GST collected on charges + + Liability accounts only — GST is owed to the government, not the bank's income Required @@ -87,12 +92,29 @@ import { SponsorDDService } from '../sponsor-dd.service'; Cash at Tellers / Vault GL Account * - {{ acc.glCode }} · {{ acc.name }} + + {{ acc.glCode }} · {{ acc.name }} + Debited when cash is received from walk-in customer Required + + + DD Cash Suspense GL Account * + + + {{ acc.glCode }} · {{ acc.name }} + + + Holds a walk-in's cash until the DD is approved — must differ from the Cash GL + Required + + + Cash Suspense GL must be a different account from the Cash GL. + + Cheque Clearing Suspense GL Account @@ -176,6 +198,10 @@ import { SponsorDDService } from '../sponsor-dd.service'; .half-width { flex: 1; } + .form-error { + font-size: 12px; + margin-top: -8px; + } .hint-text { color: #64748b; font-size: 13px; @@ -203,38 +229,71 @@ export class GlSettingsDialogComponent implements OnInit { mapping: any = null; glAccounts: any[] = []; + // Each account has one job, and the job fixes its type (GLAccountType ids). The backend rejects a + // mismatch too; filtering here keeps a wrong account from being offered at all. + get assetAccounts(): any[] { + return this.glAccounts.filter((acc) => acc.type?.id === 1); + } + + get liabilityAccounts(): any[] { + return this.glAccounts.filter((acc) => acc.type?.id === 2); + } + + get incomeAccounts(): any[] { + return this.glAccounts.filter((acc) => acc.type?.id === 4); + } + + /** Cash awaiting approval is either still the bank's (asset) or already owed (liability), never P&L. */ + get suspenseAccounts(): any[] { + const cashGlAccountId = this.form?.get('cashGlAccountId')?.value; + return this.glAccounts.filter((acc) => (acc.type?.id === 1 || acc.type?.id === 2) && acc.id !== cashGlAccountId); + } + ngOnInit(): void { - this.form = this.fb.group({ - // No longer edited here — it lives on the sponsor bank. Kept in the form, unvalidated, so - // saving this screen round-trips the stored value instead of clearing it: existing banks were - // seeded from it on upgrade, and nothing should quietly erase what they were seeded from. - ddPayableGlAccountId: [null], - chargesIncomeGlAccountId: [ - null, - [Validators.required] - ], - gstPayableGlAccountId: [ - null, - [Validators.required] - ], - cashGlAccountId: [ - null, - [Validators.required] - ], - chequeClearingGlAccountId: [null], - gstPercentage: [ - 18, - [ - Validators.required, - Validators.min(0), - Validators.max(100) + this.form = this.fb.group( + { + // No longer edited here — it lives on the sponsor bank. Kept in the form, unvalidated, so + // saving this screen round-trips the stored value instead of clearing it: existing banks were + // seeded from it on upgrade, and nothing should quietly erase what they were seeded from. + ddPayableGlAccountId: [null], + chargesIncomeGlAccountId: [ + null, + [Validators.required] + ], + gstPayableGlAccountId: [ + null, + [Validators.required] + ], + cashGlAccountId: [ + null, + [Validators.required] + ], + cashSuspenseGlAccountId: [ + null, + [Validators.required] + ], + chequeClearingGlAccountId: [null], + gstPercentage: [ + 18, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + ddLimitAmount: [ + null, + [Validators.min(0)] ] - ], - ddLimitAmount: [ - null, - [Validators.min(0)] - ] - }); + }, + { + validators: (group: FormGroup) => { + const cash = group.get('cashGlAccountId')?.value; + const suspense = group.get('cashSuspenseGlAccountId')?.value; + return cash != null && cash === suspense ? { suspenseSameAsCash: true } : null; + } + } + ); this.loadGlAccountsAndMapping(); } @@ -250,15 +309,23 @@ export class GlSettingsDialogComponent implements OnInit { next: (mappings) => { this.mapping = mappings && mappings.length ? mappings[0] : null; if (this.mapping) { + // A stored account of the wrong type is not carried into the form: the field shows empty and + // required, so the next save has to pick a correct one instead of re-saving the mistake. + const ofType = (id: any, allowed: any[]) => (allowed.some((acc) => acc.id === id) ? id : null); this.form.patchValue({ ddPayableGlAccountId: this.mapping.ddPayableGlAccountId ?? null, - chargesIncomeGlAccountId: this.mapping.chargesIncomeGlAccountId ?? null, - gstPayableGlAccountId: this.mapping.gstPayableGlAccountId ?? null, - cashGlAccountId: this.mapping.cashGlAccountId ?? null, + chargesIncomeGlAccountId: ofType(this.mapping.chargesIncomeGlAccountId, this.incomeAccounts), + gstPayableGlAccountId: ofType(this.mapping.gstPayableGlAccountId, this.liabilityAccounts), + cashGlAccountId: ofType(this.mapping.cashGlAccountId, this.assetAccounts), chequeClearingGlAccountId: this.mapping.chequeClearingGlAccountId ?? null, gstPercentage: this.mapping.gstPercentage ?? 18, ddLimitAmount: this.mapping.ddLimitAmount ?? null }); + // Patched after the cash account so the suspense list already excludes it; a suspense that + // equals cash (what this dialog used to save by default) is dropped the same way. + this.form.patchValue({ + cashSuspenseGlAccountId: ofType(this.mapping.cashSuspenseGlAccountId, this.suspenseAccounts) + }); } this.loading = false; }, @@ -295,7 +362,7 @@ export class GlSettingsDialogComponent implements OnInit { chequeClearingGlAccountId: val.chequeClearingGlAccountId, cashGlAccountId: val.cashGlAccountId, chargesIncomeGlAccountId: val.chargesIncomeGlAccountId, - cashSuspenseGlAccountId: this.mapping?.cashSuspenseGlAccountId || val.cashGlAccountId, + cashSuspenseGlAccountId: val.cashSuspenseGlAccountId, gstPercentage: val.gstPercentage, gstPayableGlAccountId: val.gstPayableGlAccountId, ddLimitAmount: val.ddLimitAmount diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html index a42722cf0..234429d41 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html @@ -121,7 +121,17 @@ @for (r of requests; track r.id) { {{ r.voucher_no || '—' }} - {{ r.account_no }} + + {{ r.account_no }} + @if (r.inter_branch) { + + Inter-branch · {{ r.counter_office_name }} → {{ r.account_office_name }} + + } + {{ r.client_name }} ₹{{ r.amount | number: '1.2-2' }} {{ r.payment_type_name || 'Cash' }} @@ -173,6 +183,20 @@
+ @if (reviewRequest.inter_branch) { +
+ alt_route +
+
Inter-branch deposit
+
+ Cash was received at {{ reviewRequest.counter_office_name }} but the account is held at + {{ reviewRequest.account_office_name }}. The customer's account is credited in {{ reviewRequest.account_office_name }}'s books. Check the + account before approving. +
+
+
+ }
Account No diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss index 06fc3df0a..816bdd58f 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.scss @@ -463,6 +463,51 @@ } } +/* Shown on a deposit whose cash was taken at one branch for an account held at another. */ +.dri-interbranch { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0 0 12px; + padding: 12px 16px; + border: 1px solid #fcd34d; + border-left: 4px solid #f59e0b; + border-radius: 8px; + background: #fffbeb; + color: #78350f; + + mat-icon { + flex-shrink: 0; + color: #d97706; + } + + &__title { + font-size: 13px; + font-weight: 800; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + &__text { + margin-top: 2px; + font-size: 13px; + line-height: 1.5; + } +} + +.dri-branch-badge { + display: block; + margin-top: 4px; + width: fit-content; + padding: 1px 8px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-family: inherit; + font-size: 11px; + font-weight: 700; +} + .dri-detail-grid { display: grid; grid-template-columns: 1fr 1fr;