Add CC accounts tracking, AML rule trial and branch approval fixes
This commit is contained in:
@@ -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<any> {
|
||||
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.
|
||||
|
||||
@@ -139,6 +139,13 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="limit">
|
||||
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Sanctioned Limit</th>
|
||||
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
|
||||
{{ getCurrencySymbol(row) }} {{ row.overdraftLimit | formatNumber }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="balance">
|
||||
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
|
||||
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,32 @@ export class AmlService {
|
||||
return this.http.get<any[]>('/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<any[]> {
|
||||
return this.http.get<any[]>('/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<any> {
|
||||
return this.http.get<any>('/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<any> {
|
||||
return this.http.get<any>(`/aml/cc-tracking/${accountId}/trail`, {
|
||||
params: new HttpParams().set('days', String(days))
|
||||
});
|
||||
}
|
||||
|
||||
/** month format: yyyy-MM */
|
||||
generateCtrReport(month: string): Observable<any> {
|
||||
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||
|
||||
@@ -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<unknown>;
|
||||
private requestsRef: MatDialogRef<unknown> | 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);
|
||||
}
|
||||
|
||||
@@ -132,12 +132,12 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branches.length <= 1"
|
||||
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@for (b of branches; track b.id) {
|
||||
<option [ngValue]="b.id">{{ b.label }}</option>
|
||||
@for (b of branchSelectOptions; track b.id) {
|
||||
<option [ngValue]="b.id">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@@ -722,7 +722,24 @@
|
||||
} @else {
|
||||
<div class="te-panel__body">
|
||||
<div class="te-field">
|
||||
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
|
||||
<span class="te-label">Branch</span
|
||||
><span class="te-value">
|
||||
@if (isOwnBranchAccount !== null) {
|
||||
<span
|
||||
class="te-branch-chip"
|
||||
[class.te-branch-chip--mine]="isOwnBranchAccount"
|
||||
[class.te-branch-chip--other]="!isOwnBranchAccount"
|
||||
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
|
||||
>
|
||||
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
|
||||
@if (!isOwnBranchAccount) {
|
||||
<span class="te-branch-chip__tag">Other branch</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
{{ officeName }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">A/c No.</span
|
||||
@@ -1201,7 +1218,24 @@
|
||||
: '—')
|
||||
}}</span
|
||||
>
|
||||
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
|
||||
<span class="te-acs-kv"
|
||||
><b>Branch</b>
|
||||
@if (isOwnBranchAccount !== null) {
|
||||
<span
|
||||
class="te-branch-chip"
|
||||
[class.te-branch-chip--mine]="isOwnBranchAccount"
|
||||
[class.te-branch-chip--other]="!isOwnBranchAccount"
|
||||
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
|
||||
>
|
||||
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
|
||||
@if (!isOwnBranchAccount) {
|
||||
<span class="te-branch-chip__tag">Other branch</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
{{ officeName }}
|
||||
}
|
||||
</span>
|
||||
<span class="te-acs-kv"
|
||||
><b>Product</b>
|
||||
{{
|
||||
@@ -1592,12 +1626,12 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branches.length <= 1"
|
||||
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@for (b of branches; track b.id) {
|
||||
<option [ngValue]="b.id">{{ b.label }}</option>
|
||||
@for (b of branchSelectOptions; track b.id) {
|
||||
<option [ngValue]="b.id">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<any[]>('/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<any>(`/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 ??
|
||||
|
||||
@@ -102,6 +102,27 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Money moving between branches: the approver must see it before approving -->
|
||||
@if (isInterBranch) {
|
||||
<div class="dri-interbranch">
|
||||
<mat-icon>alt_route</mat-icon>
|
||||
<div>
|
||||
<div class="dri-interbranch__title">Inter-branch transfer</div>
|
||||
<div class="dri-interbranch__text">
|
||||
@if (branchNames(drEntries) && branchNames(crEntries)) {
|
||||
Money moves from <strong>{{ branchNames(drEntries) }}</strong> to
|
||||
<strong>{{ branchNames(crEntries) }}</strong
|
||||
>.
|
||||
} @else {
|
||||
These accounts are held at different branches: <strong>{{ branchNames(creditEntries) }}</strong
|
||||
>.
|
||||
}
|
||||
Each account is posted in its own branch's books. Check the accounts before approving.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Bank Transfer summary card (replaces GL card) -->
|
||||
@if (isBankTransferMode) {
|
||||
<div class="dri-card">
|
||||
@@ -154,6 +175,7 @@
|
||||
<th>Account No</th>
|
||||
<th>Account Name</th>
|
||||
<th>Type</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">{{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -166,11 +188,16 @@
|
||||
<td>
|
||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
||||
entry.officeName || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="dri-cr-total-row">
|
||||
<td colspan="4" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
|
||||
<td colspan="5" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
|
||||
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -192,6 +219,7 @@
|
||||
<th>Account No</th>
|
||||
<th>Account Name</th>
|
||||
<th>Type</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Amount (DR)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -204,11 +232,16 @@
|
||||
<td>
|
||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
||||
entry.officeName || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="dri-cr-total-row">
|
||||
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
|
||||
<td colspan="5" class="dri-cr-total-label">Total Debit</td>
|
||||
<td class="num dri-cr-total-amount">₹{{ totalDebitAmount() | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -228,6 +261,7 @@
|
||||
<th>Account No</th>
|
||||
<th>Account Name</th>
|
||||
<th>Type</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Amount (CR)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -240,11 +274,16 @@
|
||||
<td>
|
||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
||||
entry.officeName || '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="dri-cr-total-row">
|
||||
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
|
||||
<td colspan="5" class="dri-cr-total-label">Total Credit</td>
|
||||
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+11
-1
@@ -930,6 +930,10 @@
|
||||
createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched
|
||||
) {
|
||||
<mat-error>Enter valid 10-digit number (starts with 6-9)</mat-error>
|
||||
} @else if (createClientForm.get('mobileNo')?.hasError('contactTaken')) {
|
||||
<mat-error
|
||||
>This mobile number already belongs to another customer. Please enter a different number.</mat-error
|
||||
>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@@ -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()"
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="m-r-10"></fa-icon>
|
||||
@@ -1008,6 +1016,8 @@
|
||||
createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched
|
||||
) {
|
||||
<mat-error>Email is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.get('emailAddress')?.hasError('contactTaken')) {
|
||||
<mat-error>This email already belongs to another customer. Please enter a different email.</mat-error>
|
||||
} @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) {
|
||||
<mat-error>Enter a valid email (e.g. name@example.com)</mat-error>
|
||||
}
|
||||
|
||||
+47
-10
@@ -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);
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<section class="identity-check" [class.identity-check--blocking]="status === 'matched' || status === 'conflict'">
|
||||
<header class="identity-check__header">
|
||||
<mat-icon class="identity-check__icon">fingerprint</mat-icon>
|
||||
<div>
|
||||
<h3 class="identity-check__title">Existing customer check</h3>
|
||||
<p class="identity-check__hint">
|
||||
@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.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (form) {
|
||||
<form [formGroup]="form" class="identity-check__fields">
|
||||
@if (isEntity) {
|
||||
<mat-form-field appearance="outline" class="identity-check__field">
|
||||
<mat-label>GSTIN</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="gstin"
|
||||
class="identity-check__upper"
|
||||
maxlength="15"
|
||||
mifosxRestrictInput="gstin"
|
||||
[errorStateMatcher]="errorMatcher"
|
||||
placeholder="e.g. 22AAAAA0000A1Z5"
|
||||
(input)="capitalise('gstin', $event)"
|
||||
/>
|
||||
@if (form.get('gstin')?.hasError('pattern')) {
|
||||
<mat-error>Invalid GSTIN. It has 15 characters, like 22AAAAA0000A1Z5</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="identity-check__field">
|
||||
<mat-label>{{ isEntity ? 'Company PAN' : 'PAN' }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="pan"
|
||||
class="identity-check__upper"
|
||||
maxlength="10"
|
||||
mifosxRestrictInput="pan"
|
||||
[errorStateMatcher]="errorMatcher"
|
||||
placeholder="e.g. ABCDE1234F"
|
||||
(input)="capitalise('pan', $event)"
|
||||
/>
|
||||
@if (form.get('pan')?.hasError('pattern')) {
|
||||
<mat-error>Invalid PAN. Use 5 letters, 4 digits, 1 letter, like ABCDE1234F</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (!isEntity) {
|
||||
<mat-form-field appearance="outline" class="identity-check__field">
|
||||
<mat-label>Aadhaar</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaar"
|
||||
maxlength="12"
|
||||
inputmode="numeric"
|
||||
mifosxRestrictInput="digits"
|
||||
[errorStateMatcher]="errorMatcher"
|
||||
placeholder="12 digits"
|
||||
/>
|
||||
@if (form.get('aadhaar')?.hasError('pattern')) {
|
||||
<mat-error>Invalid Aadhaar. It must be exactly 12 digits</mat-error>
|
||||
} @else {
|
||||
<mat-hint>Only a fingerprint of this number is sent, never the number</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
@switch (status) {
|
||||
@case ('checking') {
|
||||
<div class="identity-check__status identity-check__status--info">
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
|
||||
Checking all branches…
|
||||
</div>
|
||||
}
|
||||
@case ('not-found') {
|
||||
<div class="identity-check__status identity-check__status--ok">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span>
|
||||
No existing customer found. You can continue; these details will be filled into the KYC step so you do not
|
||||
type them again.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<div class="identity-check__status identity-check__status--warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>
|
||||
Could not check right now. You can continue; the PAN, Aadhaar and GSTIN are checked again when the KYC is
|
||||
saved.
|
||||
</span>
|
||||
<button mat-button type="button" (click)="recheck()">Check again</button>
|
||||
</div>
|
||||
}
|
||||
@case ('conflict') {
|
||||
<div class="identity-check__status identity-check__status--error">
|
||||
<mat-icon>report</mat-icon>
|
||||
<span>
|
||||
These details belong to <strong>different customers</strong>. Check what was typed. If they are correct, ask a
|
||||
manager to review before continuing.
|
||||
</span>
|
||||
</div>
|
||||
@for (m of matches; track m.clientId) {
|
||||
<div class="identity-check__card">
|
||||
<strong>{{ m.displayName }}</strong>
|
||||
<span class="identity-check__meta">CIF {{ m.cifNumber }} · {{ m.officeName }}</span>
|
||||
<span class="identity-check__chips">
|
||||
@for (by of m.matchedBy; track by) {
|
||||
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case ('matched') {
|
||||
@if (match; as m) {
|
||||
<div class="identity-check__status identity-check__status--error">
|
||||
<mat-icon>person_search</mat-icon>
|
||||
<span> This is an <strong>existing customer</strong>. A new client is not created for the same person. </span>
|
||||
</div>
|
||||
<div class="identity-check__card">
|
||||
<div class="identity-check__card-head">
|
||||
<strong>{{ m.displayName }}</strong>
|
||||
<span class="identity-check__chips">
|
||||
@for (by of m.matchedBy; track by) {
|
||||
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<dl class="identity-check__facts">
|
||||
<div>
|
||||
<dt>CIF</dt>
|
||||
<dd>{{ m.cifNumber }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Home branch</dt>
|
||||
<dd>{{ m.officeName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Type</dt>
|
||||
<dd>{{ legalFormLabel(m) }}</dd>
|
||||
</div>
|
||||
@if (m.panMasked) {
|
||||
<div>
|
||||
<dt>PAN</dt>
|
||||
<dd>{{ m.panMasked }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (m.aadhaarMasked) {
|
||||
<div>
|
||||
<dt>Aadhaar</dt>
|
||||
<dd>{{ m.aadhaarMasked }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (m.gstinMasked) {
|
||||
<div>
|
||||
<dt>GSTIN</dt>
|
||||
<dd>{{ m.gstinMasked }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
@if (m.visibleToCaller) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="identity-check__action"
|
||||
[routerLink]="['/clients', m.clientId, 'general']"
|
||||
>
|
||||
Open this customer
|
||||
</button>
|
||||
} @else {
|
||||
<p class="identity-check__note">
|
||||
This customer is registered at <strong>{{ m.officeName }}</strong
|
||||
>. 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.
|
||||
</p>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="identity-check__action"
|
||||
[disabled]="linking"
|
||||
(click)="openAccountHere(m)"
|
||||
>
|
||||
{{ linking ? 'Please wait…' : 'Open account for this customer' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
+157
@@ -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;
|
||||
}
|
||||
}
|
||||
+308
@@ -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<ClientIdentityState>();
|
||||
|
||||
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<void>();
|
||||
private recheck$ = new Subject<void>();
|
||||
|
||||
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<ClientIdentityLookupResult | 'error' | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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
|
||||
|
||||
@@ -356,6 +356,15 @@
|
||||
<button mat-menu-item *mifosxHasPermission="'CREATE_CURRENTACCOUNT'" [routerLink]="['current-accounts', 'create']">
|
||||
{{ 'labels.buttons.New Current Account' | translate }}
|
||||
</button>
|
||||
<!-- A Cash Credit account is a current account on a product that allows an overdraft against a sanctioned limit. -->
|
||||
<button
|
||||
mat-menu-item
|
||||
*mifosxHasPermission="'CREATE_CURRENTACCOUNT'"
|
||||
[routerLink]="['current-accounts', 'create']"
|
||||
[queryParams]="{ cc: 1 }"
|
||||
>
|
||||
New CC Account
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #Actions="matMenu">
|
||||
|
||||
@@ -90,6 +90,21 @@
|
||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Last Active">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||
@@ -203,6 +218,21 @@
|
||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Closed Date">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
||||
@@ -262,6 +292,21 @@
|
||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Last Active">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||
@@ -452,6 +497,21 @@
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fixed Deposit Product' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.productName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Last Active">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||
@@ -551,6 +611,21 @@
|
||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Closed Date">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
||||
@@ -611,10 +686,27 @@
|
||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Branch">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
@if (element.officeName) {
|
||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
||||
@if (isHomeBranch(element)) {
|
||||
<span class="branch-chip__home">Home</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="Status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
|
||||
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{
|
||||
rdStatusLabel(element)
|
||||
}}</span>
|
||||
@if (element.timeline?.closedOnDate) {
|
||||
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<number>();
|
||||
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;
|
||||
|
||||
@@ -50,6 +50,17 @@
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
@if (isHeadOffice) {
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-select [(value)]="officeFilter" (selectionChange)="officeChanged()" placeholder="Branch">
|
||||
<mat-option [value]="null">All Branches</mat-option>
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-select [(value)]="statusFilter" (selectionChange)="applyClientFilters()" placeholder="Status">
|
||||
<mat-option value="">All Status</mat-option>
|
||||
@@ -92,6 +103,11 @@
|
||||
row.legalForm?.id === 2 ? 'business' : 'person'
|
||||
}}</mat-icon>
|
||||
{{ hideClientData ? maskName(row.displayName) : row.displayName }}
|
||||
@if (!isHeadOffice && row.officeId && myOfficeId !== null && row.officeId !== myOfficeId) {
|
||||
<span class="row-home-chip" matTooltip="Registered at another branch; linked to this branch">
|
||||
Home: {{ row.officeName }}
|
||||
</span>
|
||||
}
|
||||
@if (row._ckycDone) {
|
||||
<mat-icon class="row-ckyc-icon" matTooltip="CKYC Done">verified</mat-icon>
|
||||
}
|
||||
@@ -114,6 +130,12 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Branch Column (Head Office only) -->
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef>Branch</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.officeName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<any> {
|
||||
return this.http.post(`/kyc/risk-rules/${ruleId}/mark-normal`, {});
|
||||
}
|
||||
|
||||
/** Fetch code values by code ID */
|
||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/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<ClientIdentityLookupResult> {
|
||||
return this.http.post<ClientIdentityLookupResult>('/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<any> {
|
||||
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<any[]> {
|
||||
return this.http.get<any[]>(`/kyc/notice-queue${status ? `?status=${status}` : ''}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
actOnKycNotice(id: number, command: 'cancel' | 'retry'): Observable<any> {
|
||||
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<any[]> {
|
||||
return this.http.get<any[]>('/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<string, string | number | undefined>): 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<any[]> {
|
||||
@@ -1092,6 +1218,14 @@ export class ClientsService {
|
||||
return this.http.get<any[]>('/kyc/exceptions/lapsed');
|
||||
}
|
||||
|
||||
/** Clients whose re-KYC falls due within the next `days` days (default 30), soonest first. */
|
||||
getExpiringKyc(days = 30): Observable<any[]> {
|
||||
// The caller reports a failure in its own banner, so the global popup is suppressed.
|
||||
return this.http.get<any[]>(`/kyc/exceptions/expiring?days=${days}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Savings accounts the dormancy job has marked.
|
||||
*
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!createdClientId) {
|
||||
<mifosx-client-identity-check
|
||||
[legalFormType]="legalFormType"
|
||||
(identityChange)="onIdentityChange($event)"
|
||||
></mifosx-client-identity-check>
|
||||
}
|
||||
<mifosx-client-general-step
|
||||
[clientTemplate]="clientTemplate"
|
||||
[prefillData]="prefillData"
|
||||
@@ -79,6 +85,7 @@
|
||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
[identityPrefill]="identity"
|
||||
></mifosx-client-kyc-step>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<ClientDatatableStepComponent>;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<number>(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;
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1246,6 +1246,9 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/current']">
|
||||
<fa-icon icon="building" class="menu-item-icon"></fa-icon> Current
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/cc']">
|
||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> CC
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/fixed-deposits']">
|
||||
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Fixed Deposit
|
||||
</button>
|
||||
@@ -2055,11 +2058,11 @@
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('ALM')"
|
||||
(click)="rbiMenuOpen = false; openAlmReportDialog()"
|
||||
[class.hidden]="!matchesRbiSearch('ALM Average Liabilities and Assets Time Bucket Breakdown')"
|
||||
(click)="rbiMenuOpen = false; openAlmBucketBreakdownDialog()"
|
||||
>
|
||||
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Asset Liability
|
||||
Management)
|
||||
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Average Liabilities
|
||||
and Assets) – Time Bucket Breakdown
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
||||
|
||||
@@ -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<void> {
|
||||
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.
|
||||
*/
|
||||
|
||||
+29
-3
@@ -13,6 +13,18 @@
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<h3>Account Details</h3>
|
||||
</div>
|
||||
@if (isCcRequest) {
|
||||
<p class="sad-cc-note">
|
||||
@if (ccProductsMissing) {
|
||||
<strong>No CC product yet.</strong> 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 <strong>Cash Credit (CC)</strong> account: only products that allow an overdraft are listed. The
|
||||
sanctioned limit comes from the product.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
<div class="sad-grid">
|
||||
<mat-form-field appearance="outline" class="sad-field">
|
||||
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
||||
@@ -838,9 +850,23 @@
|
||||
<span class="ni-mono">{{ c.clientCif || '—' }}</span>
|
||||
</td>
|
||||
<td class="ni-td ni-td--center">
|
||||
<span class="ni-pct-badge" [class.ni-pct-badge--full]="c.sharePercentage === 100">
|
||||
{{ c.sharePercentage }}%
|
||||
</span>
|
||||
@if (readOnly) {
|
||||
<span class="ni-pct-badge" [class.ni-pct-badge--full]="c.sharePercentage === 100">
|
||||
{{ c.sharePercentage }}%
|
||||
</span>
|
||||
} @else {
|
||||
<span class="ni-pct-input">
|
||||
<input
|
||||
#pct
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="c.sharePercentage"
|
||||
(change)="setPartnerShare(c, pct.value); pct.value = '' + c.sharePercentage"
|
||||
aria-label="Ownership percentage"
|
||||
/>%
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="ni-td ni-td--center">
|
||||
<button
|
||||
|
||||
+38
@@ -465,6 +465,31 @@ $ni-bg: #f9fafb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.ni-pct-input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: $ni-primary;
|
||||
|
||||
input {
|
||||
width: 52px;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 6px;
|
||||
background: #eef2ff;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: right;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid $ni-primary;
|
||||
outline-offset: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ni-pct-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1710,3 +1735,16 @@ $ni-bg: #f9fafb;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shown when the screen was opened from "New CC Account". */
|
||||
.sad-cc-note {
|
||||
margin: 0 0 14px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 4px solid #1d4ed8;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
+83
@@ -29,6 +29,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { IntroducerSearchComponent } from 'app/shared/introducer-search/introducer-search.component';
|
||||
import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add-related-client-dialog.component';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ProductsService } from 'app/products/products.service';
|
||||
|
||||
/**
|
||||
* Current Account Details Step
|
||||
@@ -56,6 +60,13 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
private alertService = inject(AlertService);
|
||||
private dateUtils = inject(Dates);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private route = inject(ActivatedRoute);
|
||||
private productsService = inject(ProductsService);
|
||||
|
||||
/** Opened from "New CC Account": only products that allow an overdraft are offered. */
|
||||
isCcRequest = false;
|
||||
/** A CC account was asked for but no current product allows an overdraft yet. */
|
||||
ccProductsMissing = false;
|
||||
|
||||
@Input() currentAccountTemplate: any;
|
||||
@Input() hideButtons = false;
|
||||
@@ -207,12 +218,20 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
this.autoDetectAccountStatus();
|
||||
}
|
||||
}
|
||||
if (this.isNonIndividualClient && !this.isEditMode && !this.readOnly) {
|
||||
this.prefillPartnersFromClientSignatories(client);
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
this.isCcRequest = this.route.snapshot.queryParamMap.get('cc') === '1';
|
||||
|
||||
if (this.currentAccountTemplate) {
|
||||
this.productData = this.currentAccountTemplate.productOptions;
|
||||
if (this.isCcRequest && !this.currentAccountTemplate.accountNo) {
|
||||
this.limitProductsToCc();
|
||||
}
|
||||
const existingProductId =
|
||||
this.currentAccountTemplate.currentAccountProductId ?? this.currentAccountTemplate.productId;
|
||||
// Edit mode: account response always has accountNo; pure template has previewAccountNo only
|
||||
@@ -250,6 +269,24 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Cash Credit account takes its overdraft and limit from the product it is opened on, so a CC account is opened on a
|
||||
* product that allows an overdraft. The template lists every current product without saying which allow one, so the
|
||||
* product list is read to tell them apart.
|
||||
*/
|
||||
private limitProductsToCc(): void {
|
||||
this.productsService
|
||||
.getCurrentProducts()
|
||||
.pipe(catchError(() => of([] as any[])))
|
||||
.subscribe((res: any) => {
|
||||
const products: any[] = Array.isArray(res) ? res : (res?.pageItems ?? []);
|
||||
const overdraftIds = new Set(products.filter((p) => p.allowOverdraft).map((p) => p.id));
|
||||
this.productData = (this.productData || []).filter((p: any) => overdraftIds.has(p.id));
|
||||
this.ccProductsMissing = this.productData.length === 0;
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
createCurrentAccountDetailsForm() {
|
||||
this.currentAccountDetailsForm = this.formBuilder.group({
|
||||
productId: [
|
||||
@@ -622,6 +659,52 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
}
|
||||
|
||||
// ── Non-Individual (existing) helpers ──────────────────────────────────
|
||||
/**
|
||||
* A new entity account starts with the authorised signatories recorded on the client at creation
|
||||
* (clientNonPersonDetails.authorizedSignatoryClientIds), so they need not be added again. Ownership is
|
||||
* not captured at client creation, so it starts at 0 and is entered in the table; Add Partner stays
|
||||
* available for anyone else.
|
||||
*/
|
||||
private prefillPartnersFromClientSignatories(client: any): void {
|
||||
if (this.relatedClients.length > 0) {
|
||||
return;
|
||||
}
|
||||
const ids: number[] = String(client?.clientNonPersonDetails?.authorizedSignatoryClientIds ?? '')
|
||||
.split(',')
|
||||
.map((id) => Number(id.trim()))
|
||||
.filter((id, i, all) => id > 0 && id !== this.primaryClientId && all.indexOf(id) === i);
|
||||
if (!ids.length) {
|
||||
return;
|
||||
}
|
||||
// One lookup per signatory for the CIF; a signatory that can no longer be read is skipped, not fatal.
|
||||
forkJoin(
|
||||
ids.map((id) => this.clientsService.getClientData(String(id), true).pipe(catchError(() => of(null))))
|
||||
).subscribe((signatories: any[]) => {
|
||||
// The user may have added partners by hand while the lookups ran — never overwrite those.
|
||||
if (this.relatedClients.length > 0) {
|
||||
return;
|
||||
}
|
||||
this.relatedClients = signatories
|
||||
.filter((s) => !!s?.id)
|
||||
.map((s) => ({
|
||||
clientId: s.id,
|
||||
clientName: s.displayName ?? s.fullname ?? '',
|
||||
clientCif: s.cifNumber ?? s.cifNo ?? '',
|
||||
sharePercentage: 0,
|
||||
isMandatory: false
|
||||
}));
|
||||
this.autoSetSignatoryRights();
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
/** Inline ownership edit — clamped so the partners' total never exceeds 100%. */
|
||||
setPartnerShare(client: any, raw: any): void {
|
||||
const othersTotal = this.totalSharePercentage - (client.sharePercentage || 0);
|
||||
const value = Math.round(Number(raw) || 0);
|
||||
client.sharePercentage = Math.min(Math.max(value, 0), Math.max(0, 100 - othersTotal));
|
||||
}
|
||||
|
||||
openAddRelatedClientDialog(): void {
|
||||
const excludedClientIds = [
|
||||
...(this.primaryClientId ? [this.primaryClientId] : []),
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Directive, HostListener, Input, ElementRef, inject } from '@angular/cor
|
||||
import { NgControl } from '@angular/forms';
|
||||
|
||||
/** The kinds of field this knows how to hold to a shape. */
|
||||
export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar';
|
||||
export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar' | 'gstin' | 'tan';
|
||||
|
||||
interface Rule {
|
||||
/** Characters this kind will accept at all. */
|
||||
@@ -27,7 +27,10 @@ const RULES: Record<RestrictKind, Rule> = {
|
||||
// A PAN is five letters, four digits, one letter - and is always written in capitals.
|
||||
pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true },
|
||||
// Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits.
|
||||
aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true }
|
||||
aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true },
|
||||
// GSTIN is fifteen letters and digits, TAN ten; both are always written in capitals.
|
||||
gstin: { allowed: /[A-Za-z0-9]/, maxLength: 15, upper: true },
|
||||
tan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true }
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface CtrTransaction {
|
||||
declaration: string;
|
||||
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
|
||||
customerName?: string;
|
||||
/** True for a transaction that has crossed the threshold but is not yet in a generated CTR. */
|
||||
pendingFiling?: boolean;
|
||||
/**
|
||||
* CTR does not judge suspicion. It records only whether the source of funds was
|
||||
* evidenced at the counter (CTR filed, nothing further) or could not be, in which
|
||||
|
||||
+232
-140
@@ -223,6 +223,15 @@
|
||||
<div class="fiu-note">Loading the generated CTR lines…</div>
|
||||
} @else if (ctrLinesError) {
|
||||
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
|
||||
} @else if (ctrPendingCount > 0) {
|
||||
<div class="fiu-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
{{ ctrPendingCount }} cash transaction{{ ctrPendingCount > 1 ? 's' : '' }} of ₹10,00,000 or more
|
||||
{{ ctrPendingCount > 1 ? 'are' : 'is' }} listed below automatically as soon as
|
||||
{{ ctrPendingCount > 1 ? 'they are' : 'it is' }} posted.
|
||||
{{ ctrPendingCount > 1 ? 'They are' : 'It is' }} not in a filed report yet — pick the month under “Generate
|
||||
CTR” and run it to include {{ ctrPendingCount > 1 ? 'them' : 'it' }}.
|
||||
</div>
|
||||
} @else if (!ctrTxns.length) {
|
||||
<div class="fiu-note fiu-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
@@ -342,27 +351,103 @@
|
||||
<th class="ta-c">Records</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted On</th>
|
||||
<th class="ta-c">Customers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of amlCtrReports; track r.id) {
|
||||
<tr>
|
||||
<tr style="cursor: pointer" (click)="openCtrReport(r)">
|
||||
<td>{{ r.reportMonth }}</td>
|
||||
<td>{{ r.generatedOn | date: 'medium' }}</td>
|
||||
<td>{{ r.generatedOn ? (asDate(r.generatedOn) | date: 'medium') : '—' }}</td>
|
||||
<td class="ta-c">{{ r.recordCount }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
|
||||
<td>{{ r.submittedOn ? (asDate(r.submittedOn) | date: 'medium') : '—' }}</td>
|
||||
<td class="ta-c" (click)="$event.stopPropagation()">
|
||||
<button class="fiu-btn fiu-btn-g" (click)="openCtrReport(r)">View</button>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="exportCtrReport(r)">Excel</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
|
||||
<td colspan="6" class="fiu-empty">No CTR reports generated yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- The customers and transactions one CTR report names. Opened from a row above; declared here so it keeps this
|
||||
component's state and styles. -->
|
||||
<ng-template #ctrReportDialog>
|
||||
<div class="fiu-dialog">
|
||||
@if (ctrReportView; as report) {
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>CTR report — {{ report.reportMonth }}</h3>
|
||||
<p>
|
||||
{{ report.status }} · {{ ctrReportLines.length }} record{{ ctrReportLines.length === 1 ? '' : 's' }} ·
|
||||
total ₹{{ ctrReportTotal | number: '1.2-2' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="fiu-action-group">
|
||||
<button
|
||||
class="fiu-btn fiu-btn-xml"
|
||||
[disabled]="ctrReportLinesLoading"
|
||||
(click)="exportCtrReport(report)"
|
||||
>
|
||||
Export Excel
|
||||
</button>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="closeCtrReport()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (ctrReportLinesLoading) {
|
||||
<div class="fiu-note">Loading the customers in this report…</div>
|
||||
} @else {
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Customer ID / CKYC</th>
|
||||
<th>Account</th>
|
||||
<th>Branch</th>
|
||||
<th>Date</th>
|
||||
<th>Direction</th>
|
||||
<th>Type</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of ctrReportLines; track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
{{ l.clientName }}
|
||||
<div style="font-size: 11px; color: var(--f-g500)">
|
||||
{{ l.individual === false ? 'Entity' : 'Individual' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono">{{ l.ckycNumber || l.clientExternalId || '—' }}</td>
|
||||
<td class="mono">{{ l.accountNo || '—' }}</td>
|
||||
<td>{{ l.branchName || '—' }}</td>
|
||||
<td>{{ l.transactionDate ? (asDate(l.transactionDate) | date: 'dd MMM yyyy') : '—' }}</td>
|
||||
<td>{{ l.direction || '—' }}</td>
|
||||
<td>{{ l.connectedGroup ? 'Connected group (month total)' : 'Single transaction' }}</td>
|
||||
<td class="ta-r">₹{{ l.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="fiu-empty">This report names no customers.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -581,146 +666,153 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedStrCase) {
|
||||
<div class="str-case-detail">
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
|
||||
<p>
|
||||
Alert #{{ selectedStrCase.alertId }} ·
|
||||
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
|
||||
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
|
||||
<span class="escalation-badge">Risk category auto-escalated</span>
|
||||
<!-- The case investigation. Opened as a popup by selectStrCase(); declared here so it keeps this component's state. -->
|
||||
<ng-template #strCaseDialog>
|
||||
<div class="fiu-dialog">
|
||||
@if (selectedStrCase) {
|
||||
<div class="str-case-detail">
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
|
||||
<p>
|
||||
Alert #{{ selectedStrCase.alertId }} ·
|
||||
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
|
||||
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
|
||||
<span class="escalation-badge">Risk category auto-escalated</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="case-outcome"
|
||||
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
|
||||
[class.case-outcome--suspicious]="
|
||||
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
|
||||
"
|
||||
>
|
||||
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
|
||||
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||
<div class="form-row">
|
||||
<label for="fiuInvestigationNotes"
|
||||
>Investigation notes (source-of-transaction history reviewed)</label
|
||||
>
|
||||
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
|
||||
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
|
||||
</div>
|
||||
<!-- Verification checklist — the verdict is derived, not chosen. -->
|
||||
<div class="checklist">
|
||||
<div class="checklist-head">
|
||||
<span>Principal Officer verification — mark each point as found</span>
|
||||
<span class="checklist-progress" [class.done]="allAnswered">
|
||||
{{ answeredCount }} / {{ strChecklist.length }} answered
|
||||
</span>
|
||||
</div>
|
||||
<table class="checklist-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Verification point</th>
|
||||
<th class="ta-c col-yes">Genuine</th>
|
||||
<th class="ta-c col-no">Not satisfied</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of strChecklist; track item.key) {
|
||||
<tr
|
||||
[class.row-yes]="strFindings[item.key] === true"
|
||||
[class.row-no]="strFindings[item.key] === false"
|
||||
[class.row-pending]="strFindings[item.key] === null"
|
||||
>
|
||||
<td>
|
||||
<div class="chk-label">{{ item.label }}</div>
|
||||
<div class="chk-hint">{{ item.hint }}</div>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<button
|
||||
type="button"
|
||||
class="chk-box chk-box--yes"
|
||||
[class.on]="strFindings[item.key] === true"
|
||||
[attr.aria-pressed]="strFindings[item.key] === true"
|
||||
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
|
||||
(click)="setFinding(item.key, true)"
|
||||
>
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<button
|
||||
type="button"
|
||||
class="chk-box chk-box--no"
|
||||
[class.on]="strFindings[item.key] === false"
|
||||
[attr.aria-pressed]="strFindings[item.key] === false"
|
||||
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
|
||||
(click)="setFinding(item.key, false)"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Derived verdict -->
|
||||
<div
|
||||
class="verdict"
|
||||
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
|
||||
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
|
||||
>
|
||||
@if (!allAnswered) {
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>
|
||||
Answer every verification point to determine the outcome —
|
||||
{{ strChecklist.length - answeredCount }} still pending.
|
||||
</span>
|
||||
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<span>
|
||||
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case
|
||||
will be closed and nothing filed with FIU-IND.
|
||||
</span>
|
||||
} @else {
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>
|
||||
<strong>Suspicious — STR must be filed.</strong>
|
||||
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
|
||||
{{ failedFindings[0].label }}
|
||||
@if (failedFindings.length > 1) {
|
||||
<span> and {{ failedFindings.length - 1 }} more</span>
|
||||
}
|
||||
.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
|
||||
Conclude as
|
||||
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
|
||||
</button>
|
||||
|
||||
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
||||
<div class="file-str-row">
|
||||
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
|
||||
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
|
||||
</div>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="case-outcome"
|
||||
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
|
||||
[class.case-outcome--suspicious]="
|
||||
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
|
||||
"
|
||||
>
|
||||
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
|
||||
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||
<div class="form-row">
|
||||
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
|
||||
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
|
||||
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
|
||||
</div>
|
||||
<!-- Verification checklist — the verdict is derived, not chosen. -->
|
||||
<div class="checklist">
|
||||
<div class="checklist-head">
|
||||
<span>Principal Officer verification — mark each point as found</span>
|
||||
<span class="checklist-progress" [class.done]="allAnswered">
|
||||
{{ answeredCount }} / {{ strChecklist.length }} answered
|
||||
</span>
|
||||
</div>
|
||||
<table class="checklist-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Verification point</th>
|
||||
<th class="ta-c col-yes">Genuine</th>
|
||||
<th class="ta-c col-no">Not satisfied</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of strChecklist; track item.key) {
|
||||
<tr
|
||||
[class.row-yes]="strFindings[item.key] === true"
|
||||
[class.row-no]="strFindings[item.key] === false"
|
||||
[class.row-pending]="strFindings[item.key] === null"
|
||||
>
|
||||
<td>
|
||||
<div class="chk-label">{{ item.label }}</div>
|
||||
<div class="chk-hint">{{ item.hint }}</div>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<button
|
||||
type="button"
|
||||
class="chk-box chk-box--yes"
|
||||
[class.on]="strFindings[item.key] === true"
|
||||
[attr.aria-pressed]="strFindings[item.key] === true"
|
||||
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
|
||||
(click)="setFinding(item.key, true)"
|
||||
>
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<button
|
||||
type="button"
|
||||
class="chk-box chk-box--no"
|
||||
[class.on]="strFindings[item.key] === false"
|
||||
[attr.aria-pressed]="strFindings[item.key] === false"
|
||||
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
|
||||
(click)="setFinding(item.key, false)"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Derived verdict -->
|
||||
<div
|
||||
class="verdict"
|
||||
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
|
||||
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
|
||||
>
|
||||
@if (!allAnswered) {
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>
|
||||
Answer every verification point to determine the outcome —
|
||||
{{ strChecklist.length - answeredCount }} still pending.
|
||||
</span>
|
||||
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<span>
|
||||
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will be
|
||||
closed and nothing filed with FIU-IND.
|
||||
</span>
|
||||
} @else {
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>
|
||||
<strong>Suspicious — STR must be filed.</strong>
|
||||
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
|
||||
{{ failedFindings[0].label }}
|
||||
@if (failedFindings.length > 1) {
|
||||
<span> and {{ failedFindings.length - 1 }} more</span>
|
||||
}
|
||||
.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
|
||||
Conclude as
|
||||
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
|
||||
</button>
|
||||
|
||||
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
||||
<div class="file-str-row">
|
||||
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
|
||||
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
|
||||
<div class="fiu-form-meta">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<unknown>;
|
||||
private strCaseDialogRef: MatDialogRef<unknown> | null = null;
|
||||
|
||||
/** The CTR report whose customers are open in the popup, and the lines behind it. */
|
||||
@ViewChild('ctrReportDialog') ctrReportDialogTpl!: TemplateRef<unknown>;
|
||||
private ctrReportDialogRef: MatDialogRef<unknown> | 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<void> {
|
||||
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);
|
||||
|
||||
+852
@@ -0,0 +1,852 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="bkd">
|
||||
<div class="bkd-head">
|
||||
<div class="bkd-head-left">
|
||||
<div class="bkd-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
|
||||
<div>
|
||||
<h2 class="bkd-title">ALM Time-Bucket Breakdown</h2>
|
||||
<p class="bkd-sub">Day-wise breakdown of FD / RD / Loan from the as-on date</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bkd-head-actions">
|
||||
@if (view === 'config') {
|
||||
<button type="button" class="bkd-btn" [class.bkd-btn-on]="editMode" (click)="toggleEdit()">
|
||||
{{ editMode ? 'Done editing lines' : 'Edit lines' }}
|
||||
</button>
|
||||
<button type="button" class="bkd-btn" (click)="reset()">Reset to RBI defaults</button>
|
||||
}
|
||||
<button class="bkd-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bkd-tabs" role="tablist">
|
||||
<button type="button" role="tab" class="bkd-tab" [class.on]="view === 'days'" (click)="view = 'days'">
|
||||
Day-wise breakdown
|
||||
</button>
|
||||
<button type="button" role="tab" class="bkd-tab" [class.on]="view === 'config'" (click)="view = 'config'">
|
||||
Configuration table
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bkd-body">
|
||||
@if (view === 'config') {
|
||||
<p class="bkd-note">
|
||||
<fa-icon icon="info-circle"></fa-icon> Each line shows which bucket its money goes to. You can change the
|
||||
<strong>%</strong> and the rule. Changes are saved in this browser straight away. Hover over a line name to see
|
||||
why it is slotted that way.
|
||||
</p>
|
||||
|
||||
@if (problemCount > 0) {
|
||||
<p class="bkd-warn bkd-banner">
|
||||
{{ problemCount }} line(s) have a % total that is not 100 or is not filled in yet. See the red or yellow
|
||||
totals below.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
@if (view === 'config') {
|
||||
<!-- The date the figures are as on, and how they are read -->
|
||||
<div class="bkd-toolbar">
|
||||
<label>
|
||||
<span>As-on date</span>
|
||||
<input type="date" [(ngModel)]="asOnIso" (ngModelChange)="loadBreakdown()" aria-label="As-on date" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Amount unit</span>
|
||||
<select [(ngModel)]="unit" aria-label="Unit">
|
||||
@for (u of units; track u.value) {
|
||||
<option [value]="u.value">{{ u.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<div class="bkd-toolbar-state">
|
||||
@if (loading) {
|
||||
<span>Loading data from accounts…</span>
|
||||
} @else if (loadError) {
|
||||
<span class="bkd-warn">{{ loadError }}</span>
|
||||
} @else if (breakdown) {
|
||||
<span class="bkd-ok"> FD, RD and Loan data as on {{ breakdown.asOnDate }}. </span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (view === 'days') {
|
||||
<section class="bkd-days-view">
|
||||
<h4 class="bkd-h4 bkd-h4-first">1. Select</h4>
|
||||
<div class="bkd-modes" role="tablist">
|
||||
<button type="button" class="bkd-mode" [class.on]="selectBy === 'types'" (click)="setSelectBy('types')">
|
||||
By account type
|
||||
</button>
|
||||
<button type="button" class="bkd-mode" [class.on]="selectBy === 'accounts'" (click)="setSelectBy('accounts')">
|
||||
Individual accounts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (selectBy === 'types') {
|
||||
<p class="bkd-days-intro">Tick one or more. You can pick from both sides together.</p>
|
||||
@for (grp of pickGroups; track grp.side) {
|
||||
<div class="bkd-pick-group">
|
||||
<div
|
||||
class="bkd-pick-title"
|
||||
[class.bkd-pick-liab]="grp.side === 'liability'"
|
||||
[class.bkd-pick-asset]="grp.side === 'asset'"
|
||||
>
|
||||
{{ grp.title }}
|
||||
</div>
|
||||
<div class="bkd-pick-list">
|
||||
@for (t of grp.items; track t.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="bkd-pick-opt"
|
||||
[class.on]="isTypePicked(t.value)"
|
||||
role="checkbox"
|
||||
[attr.aria-checked]="isTypePicked(t.value)"
|
||||
(click)="toggleType(t.value)"
|
||||
>
|
||||
<span class="bkd-pick-box">{{ isTypePicked(t.value) ? '✓' : '' }}</span>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="bkd-pick-actions">
|
||||
<button type="button" class="bkd-link" (click)="selectAllTypes()">Select all</button>
|
||||
<button type="button" class="bkd-link" (click)="clearTypes()">Clear</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="bkd-search">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchText"
|
||||
(ngModelChange)="onSearchInput()"
|
||||
placeholder="Search by account number or customer name"
|
||||
aria-label="Search accounts"
|
||||
/>
|
||||
<select [(ngModel)]="searchKind" (ngModelChange)="onSearchInput()" aria-label="Kind of account">
|
||||
<option value="">All accounts</option>
|
||||
<option value="FD">FD</option>
|
||||
<option value="RD">RD</option>
|
||||
<option value="LOAN">Loan</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (searching) {
|
||||
<p class="bkd-days-note">Searching…</p>
|
||||
} @else if (searchError) {
|
||||
<p class="bkd-warn">{{ searchError }}</p>
|
||||
} @else if (searchText.trim().length < 2) {
|
||||
<p class="bkd-days-note">Type at least 2 characters of an account number or a customer name.</p>
|
||||
} @else if (!searchResults.length) {
|
||||
<p class="bkd-days-note">No accounts found.</p>
|
||||
}
|
||||
|
||||
@if (searchResults.length) {
|
||||
<div class="bkd-hits">
|
||||
@for (hit of searchResults; track hit.kind + '-' + hit.id) {
|
||||
<button type="button" class="bkd-hit" [class.picked]="isPicked(hit)" (click)="pick(hit)">
|
||||
<span class="bkd-hit-kind">{{ hit.kind }}</span>
|
||||
<span class="bkd-hit-main">
|
||||
<strong>{{ hit.accountNo }}</strong> · {{ hit.customer }}
|
||||
<small
|
||||
>{{ hit.product }}
|
||||
@if (hit.date) {
|
||||
· matures {{ hit.date | date: 'dd-MMM-yyyy' }}
|
||||
}
|
||||
</small>
|
||||
</span>
|
||||
<span class="bkd-hit-amt">{{ amountText(hit.amount) }}</span>
|
||||
<span class="bkd-hit-state">{{ isPicked(hit) ? 'Selected' : 'Select' }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (picked.length) {
|
||||
<div class="bkd-picked">
|
||||
<strong>Selected ({{ picked.length }})</strong>
|
||||
@for (p of picked; track p.kind + '-' + p.id) {
|
||||
<span class="bkd-pill-sel">
|
||||
{{ p.kind }} {{ p.accountNo }}
|
||||
<button type="button" (click)="unpick(p)" aria-label="Remove">✕</button>
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="bkd-link" (click)="clearPicked()">Clear all</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<h4 class="bkd-h4">2. Date and unit</h4>
|
||||
<div class="bkd-submit-row">
|
||||
<label>
|
||||
<span>As-on date</span>
|
||||
<input type="date" [(ngModel)]="asOnIso" (ngModelChange)="onAsOnChange()" aria-label="As-on date" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Amount unit</span>
|
||||
<select [(ngModel)]="unit" aria-label="Amount unit">
|
||||
@for (u of units; track u.value) {
|
||||
<option [value]="u.value">{{ u.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="bkd-btn bkd-btn-on bkd-submit" [disabled]="!canSubmit" (click)="submitReport()">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (reportShown && selectBy === 'types') {
|
||||
<section class="bkd-report-out">
|
||||
<h4 class="bkd-h4 bkd-report-title">
|
||||
{{ typeColumns.length }} account {{ typeColumns.length === 1 ? 'type' : 'types' }}
|
||||
<small>as on {{ asOnIso }} · Amounts in {{ unitWord }}</small>
|
||||
</h4>
|
||||
@if (loading) {
|
||||
<p class="bkd-days-note">Loading data from accounts…</p>
|
||||
}
|
||||
@if (loadError) {
|
||||
<p class="bkd-warn">{{ loadError }}</p>
|
||||
}
|
||||
@if (breakdown) {
|
||||
@for (c of typeColumns; track c.value) {
|
||||
@if (c.notice) {
|
||||
<p class="bkd-warn bkd-banner">{{ c.label }}: {{ c.notice }}</p>
|
||||
}
|
||||
}
|
||||
<div class="bkd-acc-wrap bkd-matrix-wrap">
|
||||
<table class="bkd-tbl bkd-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Days</th>
|
||||
<th>Dates</th>
|
||||
@for (c of typeColumns; track c.value) {
|
||||
<th
|
||||
class="bkd-num"
|
||||
[class.bkd-pick-liab]="c.side === 'liability'"
|
||||
[class.bkd-pick-asset]="c.side === 'asset'"
|
||||
>
|
||||
{{ c.label }}
|
||||
</th>
|
||||
}
|
||||
@if (typeHasLiability && typeHasAsset) {
|
||||
<th class="bkd-num bkd-pick-liab">Liabilities total</th>
|
||||
<th class="bkd-num bkd-pick-asset">Assets total</th>
|
||||
} @else if (typeColumns.length > 1) {
|
||||
<th class="bkd-num">Total</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of typeMatrix; track r.label) {
|
||||
<tr [class.bkd-dim]="!r.liab && !r.asset">
|
||||
<td class="bkd-b-name">{{ r.label }}</td>
|
||||
<td>{{ r.days }}</td>
|
||||
<td>{{ r.dates }}</td>
|
||||
@for (v of r.cells; track $index) {
|
||||
<td class="bkd-num">{{ amountText(v) }}</td>
|
||||
}
|
||||
@if (typeHasLiability && typeHasAsset) {
|
||||
<td class="bkd-num">{{ amountText(r.liab) }}</td>
|
||||
<td class="bkd-num">{{ amountText(r.asset) }}</td>
|
||||
} @else if (typeColumns.length > 1) {
|
||||
<td class="bkd-num">{{ amountText(r.liab + r.asset) }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
@for (c of typeColumns; track c.value) {
|
||||
<td class="bkd-num">{{ amountText(c.total) }}</td>
|
||||
}
|
||||
@if (typeHasLiability && typeHasAsset) {
|
||||
<td class="bkd-num">{{ amountText(liabilityGrandTotal) }}</td>
|
||||
<td class="bkd-num">{{ amountText(assetGrandTotal) }}</td>
|
||||
} @else if (typeColumns.length > 1) {
|
||||
<td class="bkd-num">{{ amountText(liabilityGrandTotal + assetGrandTotal) }}</td>
|
||||
}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<p class="bkd-days-note">
|
||||
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.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (reportShown && selectBy === 'accounts') {
|
||||
<section class="bkd-report-out">
|
||||
<h4 class="bkd-h4 bkd-report-title">
|
||||
{{ picked.length }} {{ picked.length === 1 ? 'account' : 'accounts' }}
|
||||
<small>as on {{ asOnIso }} · Amounts in {{ unitWord }}</small>
|
||||
</h4>
|
||||
@if (reportLoading) {
|
||||
<p class="bkd-days-note">Loading…</p>
|
||||
}
|
||||
@if (reportError) {
|
||||
<p class="bkd-warn">{{ reportError }}</p>
|
||||
}
|
||||
@if (report; as rep) {
|
||||
<table class="bkd-tbl bkd-focus-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Days</th>
|
||||
<th>Dates</th>
|
||||
<th class="bkd-num">Amount</th>
|
||||
<th class="bkd-num">%</th>
|
||||
<th class="bkd-num">Entries</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of reportBuckets; track r.label) {
|
||||
<tr [class.bkd-dim]="!r.amount">
|
||||
<td class="bkd-b-name">{{ r.label }}</td>
|
||||
<td>{{ r.days }}</td>
|
||||
<td>{{ r.dates }}</td>
|
||||
<td class="bkd-num">{{ amountText(r.amount) }}</td>
|
||||
<td class="bkd-num">{{ r.amount ? (r.pct | number: '1.0-1') + '%' : '' }}</td>
|
||||
<td class="bkd-num">{{ r.count || '' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
<td class="bkd-num">{{ amountText(rep.total) }}</td>
|
||||
<td class="bkd-num">{{ rep.total ? '100%' : '' }}</td>
|
||||
<td class="bkd-num">{{ rep.count }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<p class="bkd-days-note">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h4 class="bkd-h4">Each entry</h4>
|
||||
<div class="bkd-acc-wrap">
|
||||
<table class="bkd-tbl bkd-acc">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Customer</th>
|
||||
<th>Product</th>
|
||||
<th>Due / maturity date</th>
|
||||
<th class="bkd-num">Days</th>
|
||||
<th>Bucket</th>
|
||||
<th>Status</th>
|
||||
<th class="bkd-num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of rep.rows; track $index) {
|
||||
<tr>
|
||||
<td class="bkd-b-name">
|
||||
{{ r.kind }} {{ r.accountNo }}
|
||||
@if (r.installment) {
|
||||
<span class="bkd-b-hint">instalment {{ r.installment }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.customer }}</td>
|
||||
<td>
|
||||
{{ r.product }}
|
||||
@if (r.note) {
|
||||
<div class="bkd-warn">{{ r.note }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.date ? (r.date | date: 'dd-MMM-yyyy') : '—' }}</td>
|
||||
<td class="bkd-num">{{ r.days === null ? '' : r.days }}</td>
|
||||
<td>{{ bucketName(r.bucket) }}</td>
|
||||
<td>{{ r.status }}</td>
|
||||
<td class="bkd-num">{{ amountText(r.amount) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="bkd-empty">Nothing to show for these accounts on this date.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<section class="bkd-setup">
|
||||
<div class="bkd-range-head">
|
||||
<h4 class="bkd-h4 bkd-h4-first">Bucket ranges</h4>
|
||||
<button type="button" class="bkd-btn bkd-btn-sm" [class.bkd-btn-on]="rangeEdit" (click)="toggleRangeEdit()">
|
||||
{{ rangeEdit ? 'Done' : 'Edit' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="bkd-days-intro">
|
||||
@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.
|
||||
}
|
||||
</p>
|
||||
<table class="bkd-tbl bkd-range">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Heading</th>
|
||||
<th>Days / Dates</th>
|
||||
@if (rangeEdit) {
|
||||
<th></th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<tr>
|
||||
<td class="bkd-range-n">{{ $index + 1 }}</td>
|
||||
<td>
|
||||
@if (fromOf($index); as f) {
|
||||
@if (rangeEdit) {
|
||||
<span class="bkd-span-edit">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
[ngModel]="f.n"
|
||||
(ngModelChange)="setFrom($index, { n: $event })"
|
||||
aria-label="Start"
|
||||
/>
|
||||
<select
|
||||
[ngModel]="f.unit"
|
||||
(ngModelChange)="setFrom($index, { unit: $event })"
|
||||
aria-label="Start unit"
|
||||
>
|
||||
<option value="day">d</option>
|
||||
<option value="month">m</option>
|
||||
<option value="year">y</option>
|
||||
</select>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="bkd-range-val">{{ f.n }} {{ unitLetter(f.unit) }}</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (b.upTo; as t) {
|
||||
@if (rangeEdit) {
|
||||
<span class="bkd-span-edit">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
[ngModel]="t.n"
|
||||
(ngModelChange)="setTo($index, { n: $event })"
|
||||
aria-label="End"
|
||||
/>
|
||||
<select
|
||||
[ngModel]="t.unit"
|
||||
(ngModelChange)="setTo($index, { unit: $event })"
|
||||
aria-label="End unit"
|
||||
>
|
||||
<option value="day">d</option>
|
||||
<option value="month">m</option>
|
||||
<option value="year">y</option>
|
||||
</select>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="bkd-range-val">{{ t.n }} {{ unitLetter(t.unit) }}</span>
|
||||
}
|
||||
} @else {
|
||||
<span class="bkd-none">end</span>
|
||||
}
|
||||
</td>
|
||||
<td class="bkd-b-name">
|
||||
{{ b.label }}
|
||||
@if (rangeProblem($index); as problem) {
|
||||
<div class="bkd-warn">{{ problem }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="bkd-b-hint">
|
||||
@if (windows[$index]; as w) {
|
||||
{{ dayRange(w) }} · {{ dateRange(w) }}
|
||||
}
|
||||
</td>
|
||||
@if (rangeEdit) {
|
||||
<td>
|
||||
@if (cfg.buckets.length > 2) {
|
||||
<button
|
||||
type="button"
|
||||
class="bkd-x"
|
||||
(click)="removeBucket($index)"
|
||||
matTooltip="Remove this bucket"
|
||||
aria-label="Remove bucket"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (rangeEdit) {
|
||||
<!-- A bucket the rules did not have yesterday: add it here, it lands in date order -->
|
||||
<div class="bkd-addbucket">
|
||||
<span class="bkd-addbucket-title">+ Add a bucket ending at:</span>
|
||||
<span class="bkd-span-edit">
|
||||
<input type="number" min="1" [(ngModel)]="newEnd.n" aria-label="End of the new bucket" />
|
||||
<select [(ngModel)]="newEnd.unit" aria-label="Unit of the new bucket">
|
||||
<option value="day">d</option>
|
||||
<option value="month">m</option>
|
||||
<option value="year">y</option>
|
||||
</select>
|
||||
</span>
|
||||
<button type="button" class="bkd-btn bkd-btn-sm" (click)="addBucket()">Add</button>
|
||||
<span class="bkd-b-hint">
|
||||
It is placed in the right position automatically and splits the bucket it falls inside into two.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="bkd-calc">
|
||||
<label>
|
||||
<span>Check a maturity date</span>
|
||||
<input type="date" [(ngModel)]="maturityIso" aria-label="Maturity date" />
|
||||
</label>
|
||||
@if (dayCheck; as c) {
|
||||
<div class="bkd-calc-out">
|
||||
@if (c.due) {
|
||||
<strong>Already due</strong> ({{ -c.days }} days ago) → <strong>{{ c.bucket }}</strong>
|
||||
} @else {
|
||||
<strong>{{ c.days }} days left</strong> → <strong>{{ c.bucket }}</strong>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="bkd-calc-out bkd-none">Pick a maturity date and its bucket will show here.</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ The configuration table, laid out like the filed statement ═══ -->
|
||||
@for (sec of sections; track sec.side) {
|
||||
<div class="bkd-sheet-wrap">
|
||||
<table class="bkd-sheet">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="3" rowspan="2" class="s-title">{{ sec.title }}</th>
|
||||
<th [attr.colspan]="cfg.buckets.length" class="s-span">RESIDUAL MATURITY</th>
|
||||
<th rowspan="2" class="s-total-h">Total</th>
|
||||
@if (editMode) {
|
||||
<th rowspan="2" class="s-tools-h">Line</th>
|
||||
}
|
||||
</tr>
|
||||
<tr>
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<th class="s-bucket">{{ b.label }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of itemsOf(sec.side); track item.id) {
|
||||
@if (item.header) {
|
||||
<tr class="s-head">
|
||||
<td class="s-ref">
|
||||
@if (editMode) {
|
||||
<input type="text" [(ngModel)]="item.ref" (ngModelChange)="changed()" aria-label="Number" />
|
||||
} @else {
|
||||
{{ item.ref }}
|
||||
}
|
||||
</td>
|
||||
<td colspan="2" class="s-name">
|
||||
@if (editMode) {
|
||||
<input type="text" [(ngModel)]="item.name" (ngModelChange)="changed()" aria-label="Naam" />
|
||||
} @else {
|
||||
{{ item.name }}
|
||||
}
|
||||
</td>
|
||||
<td [attr.colspan]="cfg.buckets.length + 1" class="s-head-fill"></td>
|
||||
@if (editMode) {
|
||||
<td class="s-tools">
|
||||
<button type="button" class="bkd-x" (click)="removeLine(item)" aria-label="Hatayein">
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
} @else {
|
||||
<tr [class.s-off]="item.included === false">
|
||||
<td class="s-ref">
|
||||
@if (editMode) {
|
||||
<input type="text" [(ngModel)]="item.ref" (ngModelChange)="changed()" aria-label="Number" />
|
||||
} @else {
|
||||
{{ item.ref }}
|
||||
}
|
||||
</td>
|
||||
<td
|
||||
class="s-name"
|
||||
[class.s-sub]="item.level === 1"
|
||||
[matTooltip]="item.reason"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
@if (editMode) {
|
||||
<input type="text" [(ngModel)]="item.name" (ngModelChange)="changed()" aria-label="Naam" />
|
||||
} @else {
|
||||
{{ item.name }}
|
||||
}
|
||||
</td>
|
||||
<td class="s-rule">
|
||||
<select
|
||||
[ngModel]="item.rule"
|
||||
(ngModelChange)="ruleChanged(item, $event)"
|
||||
aria-label="Rule"
|
||||
[matTooltip]="item.reason"
|
||||
>
|
||||
@for (r of ruleOptions; track r.value) {
|
||||
<option [value]="r.value">{{ r.label }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (item.rule === 'maturity' || item.rule === 'behavioural') {
|
||||
<!-- Which accounts fill a due-date line, or give a percentage-split line its total -->
|
||||
<select
|
||||
class="s-src"
|
||||
[(ngModel)]="item.source"
|
||||
(ngModelChange)="changed()"
|
||||
aria-label="Which accounts the amount comes from"
|
||||
matTooltip="Which accounts the amount of this line comes from"
|
||||
>
|
||||
@for (o of sourceOptions; track o.value) {
|
||||
<option [value]="o.value">{{ o.label }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</td>
|
||||
|
||||
@if (item.rule === 'maturity') {
|
||||
@if (autoAmounts(item); as amounts) {
|
||||
<!-- Filled by the accounts themselves, each in the bucket of its own date -->
|
||||
@for (v of amounts; track $index) {
|
||||
<td class="s-auto" [class.s-auto-zero]="!v">{{ amountText(v) }}</td>
|
||||
}
|
||||
<td class="s-tot s-auto-total" [matTooltip]="autoAccounts(item) + ' accounts'">
|
||||
{{ amountText(autoTotal(amounts)) }}
|
||||
</td>
|
||||
} @else {
|
||||
<td [attr.colspan]="cfg.buckets.length" class="s-due">
|
||||
@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
|
||||
}
|
||||
</td>
|
||||
<td class="s-tot">—</td>
|
||||
}
|
||||
} @else if (item.rule === 'fixed') {
|
||||
<!-- A fixed line sits in one bucket: click the bucket it should move to -->
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<td
|
||||
class="s-cell s-pick"
|
||||
[class.s-cell-on]="share(item, $index) !== null"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'Put it in ' + b.label"
|
||||
[matTooltip]="share(item, $index) !== null ? b.label : 'Yahan le jaayein: ' + b.label"
|
||||
(click)="setFixed(item, $index)"
|
||||
(keydown.enter)="setFixed(item, $index)"
|
||||
(keydown.space)="setFixed(item, $index); $event.preventDefault()"
|
||||
>
|
||||
{{ share(item, $index) !== null ? '100%' : '' }}
|
||||
</td>
|
||||
}
|
||||
} @else {
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<td class="s-cell s-cell-input" [class.s-cell-on]="share(item, $index) !== null">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="any"
|
||||
placeholder="%"
|
||||
[ngModel]="share(item, $index)"
|
||||
(ngModelChange)="setShare(item, $index, $event)"
|
||||
[attr.aria-label]="b.label"
|
||||
/>
|
||||
</td>
|
||||
}
|
||||
<td
|
||||
class="s-tot"
|
||||
[class.s-tot-ok]="rowState(item) === 'ok'"
|
||||
[class.s-tot-bad]="rowState(item) === 'bad'"
|
||||
[class.s-tot-todo]="rowState(item) === 'todo'"
|
||||
>
|
||||
{{ rowState(item) === 'todo' ? 'fill in' : rowTotal(item) + '%' }}
|
||||
</td>
|
||||
}
|
||||
|
||||
@if (editMode) {
|
||||
<td class="s-tools">
|
||||
<label class="s-incl" matTooltip="Show in report">
|
||||
<input
|
||||
type="checkbox"
|
||||
[ngModel]="item.included !== false"
|
||||
(ngModelChange)="item.included = $event; changed()"
|
||||
aria-label="In report"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" class="bkd-x" (click)="removeLine(item)" aria-label="Hatayein">
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@if (editMode) {
|
||||
<button type="button" class="bkd-btn bkd-btn-sm s-add" (click)="addLine(sec.side)">
|
||||
+ Add {{ sec.title === 'OUTFLOWS' ? 'an Outflow' : 'an Inflow' }} line
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (editMode) {
|
||||
<div class="bkd-bucket-edit">
|
||||
<div class="bkd-editor-sub">Bucket names and day span</div>
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<div class="bkd-bucket-edit-row">
|
||||
<span class="bkd-step-n">{{ $index + 1 }}</span>
|
||||
<input type="text" [(ngModel)]="b.label" (ngModelChange)="changed()" aria-label="Bucket name" />
|
||||
<input type="text" [(ngModel)]="b.hint" (ngModelChange)="changed()" aria-label="Bucket description" />
|
||||
<div class="bkd-span">
|
||||
@if (b.upTo) {
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
[(ngModel)]="b.upTo.n"
|
||||
(ngModelChange)="bucketsChanged()"
|
||||
aria-label="How far it reaches"
|
||||
/>
|
||||
<select [(ngModel)]="b.upTo.unit" (ngModelChange)="bucketsChanged()" aria-label="Unit">
|
||||
<option value="day">d</option>
|
||||
<option value="month">m</option>
|
||||
<option value="year">y</option>
|
||||
</select>
|
||||
} @else {
|
||||
<span class="bkd-none">No end</span>
|
||||
}
|
||||
<label class="bkd-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
[ngModel]="!b.upTo"
|
||||
(ngModelChange)="setOpenEnded(b, $event)"
|
||||
aria-label="No end"
|
||||
/>
|
||||
No end
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (cfg.overdue; as o) {
|
||||
<div class="bkd-editor-sub bkd-overdue-title">Overdue (due but not received): which bucket</div>
|
||||
<div class="bkd-overdue">
|
||||
<label>
|
||||
<span>Overdue less than a month</span>
|
||||
<select [(ngModel)]="o.recent" (ngModelChange)="bucketsChanged()" aria-label="Recent overdue bucket">
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<option [ngValue]="$index">{{ b.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Overdue more than a month</span>
|
||||
<select [(ngModel)]="o.old" (ngModelChange)="bucketsChanged()" aria-label="Old overdue bucket">
|
||||
@for (b of cfg.buckets; track $index) {
|
||||
<option [ngValue]="$index">{{ b.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- What the accounts could not put in a date bucket, so nothing is silently left out -->
|
||||
@if (breakdown; as b) {
|
||||
<section class="bkd-apart-box">
|
||||
<h4 class="bkd-h4">Shown separately (not slotted by date)</h4>
|
||||
<ul class="bkd-apart">
|
||||
@for (n of b.notSlotted; track n.key) {
|
||||
<li>
|
||||
<strong>{{ n.label }}</strong> — {{ n.accounts }} loan, outstanding
|
||||
{{ amountText(n.outstanding) }}
|
||||
<div class="bkd-b-hint">{{ n.why }}</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<ul class="bkd-notes">
|
||||
@for (note of b.notes; track note) {
|
||||
<li>{{ note }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- ═══ What the report will show ═══ -->
|
||||
<section class="bkd-report">
|
||||
<h3 class="bkd-h3">What the report will show</h3>
|
||||
<div class="bkd-report-card">
|
||||
<div class="r-title">Statement of Structural Liquidity as on {{ asOnIso }}</div>
|
||||
<div class="r-sub">( Amounts in {{ unitWord }} )</div>
|
||||
</div>
|
||||
<ul class="bkd-report-list">
|
||||
<li>
|
||||
Two tables, <strong>OUTFLOWS</strong> and <strong>INFLOWS</strong>, with the lines above. Each line’s amount
|
||||
sits in its bucket, with a <strong>Total</strong> in the last column.
|
||||
</li>
|
||||
<li><strong>A. TOTAL OUTFLOWS</strong> — the sum of all outflow lines, bucket by bucket.</li>
|
||||
<li><strong>B. TOTAL INFLOWS</strong> — the sum of all inflow lines, bucket by bucket.</li>
|
||||
<li><strong>C. MISMATCH (B − A)</strong> — inflow minus outflow, in each bucket.</li>
|
||||
<li>
|
||||
<strong>D. CUMULATIVE MISMATCH</strong> — running total of C, from the first bucket up to that bucket.
|
||||
</li>
|
||||
<li><strong>E. C as % to A</strong> — mismatch divided by outflow, times 100.</li>
|
||||
@for (l of cfg.limits; track $index) {
|
||||
<li class="bkd-limit">
|
||||
<strong>{{ cfg.buckets[l.bucket]?.label }}</strong
|
||||
>: negative mismatch must not exceed {{ l.pct }}%. The report shows it in red when it does.
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p class="bkd-days-note">
|
||||
Where the amount comes from: <strong>By due date</strong> lines take their data from FD, RD and Loan accounts.
|
||||
<strong>Fixed</strong>, <strong>Split</strong> and <strong>By study</strong> lines take the ledger (GL)
|
||||
balance and apply the % above. Lines with no register have to be filled in by hand.
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
+1341
File diff suppressed because it is too large
Load Diff
+962
@@ -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<AlmBucketBreakdownDialogComponent>);
|
||||
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<void>();
|
||||
|
||||
/** 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<number, AlmPlacement>();
|
||||
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<number>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<AlmItem, 'id' | 'spreads' | 'level'> & { 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 }))
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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<MaturityBreakdown> {
|
||||
const params = new HttpParams()
|
||||
.set('asOnDate', query.asOnDate)
|
||||
.set('buckets', query.buckets)
|
||||
.set('overdueRecentBucket', query.overdueRecentBucket)
|
||||
.set('overdueOldBucket', query.overdueOldBucket);
|
||||
return this.http.get<MaturityBreakdown>('/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<SelectedReport> {
|
||||
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<SelectedReport>('/almreturn/maturity-breakdown/selected', { params });
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
RBI Liabilities Register
|
||||
</div>
|
||||
<h2 class="dialog-title">Demand & Time Liabilities</h2>
|
||||
<p class="dialog-sub">NDTL · CRR/SLR Computation Base · As per RBI Master Circular</p>
|
||||
<p class="dialog-sub">What the bank owes, and what it must hold against it</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- The close being reported, and under it the close the ratios are charged on. Two dates,
|
||||
@@ -45,36 +45,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<!-- What the liabilities are, and what they come to. Four cards rather than five: the CRR
|
||||
requirement that used to sit here is on the compliance bar below, beside what is held
|
||||
against it, where it answers a question rather than repeating one. -->
|
||||
<div class="summary-row">
|
||||
<div class="summary-card sc-demand">
|
||||
<div class="sc-label">Total Demand Liabilities</div>
|
||||
<div class="sc-value">{{ crore(totalDemand) }}<span class="cr" *ngIf="totalDemand !== null">Cr</span></div>
|
||||
<div class="sc-sub">Payable on demand</div>
|
||||
<div class="sc-label">Demand liabilities</div>
|
||||
<div class="sc-value" [title]="money(totalDemand)">{{ tile(totalDemand) }}</div>
|
||||
<div class="sc-sub">Repayable on demand</div>
|
||||
</div>
|
||||
<div class="summary-card sc-time">
|
||||
<div class="sc-label">Total Time Liabilities</div>
|
||||
<div class="sc-value">{{ crore(totalTime) }}<span class="cr" *ngIf="totalTime !== null">Cr</span></div>
|
||||
<div class="sc-sub">Payable on maturity</div>
|
||||
<div class="sc-label">Time liabilities</div>
|
||||
<div class="sc-value" [title]="money(totalTime)">{{ tile(totalTime) }}</div>
|
||||
<div class="sc-sub">Repayable on maturity</div>
|
||||
</div>
|
||||
<div class="summary-card sc-asset">
|
||||
<div class="sc-label">Assets with Banking System</div>
|
||||
<div class="sc-value">
|
||||
{{ crore(totalBankingAssets) }}<span class="cr" *ngIf="totalBankingAssets !== null">Cr</span>
|
||||
<div class="sc-label">Held with other banks</div>
|
||||
<!-- Scaled on the tile, exact on hover: the card is read for order of magnitude, but the
|
||||
figure behind it should never be more than a pointer away. -->
|
||||
<div class="sc-value" [title]="money(totalBankingAssets)">
|
||||
{{ tile(totalBankingAssets) }}
|
||||
</div>
|
||||
<!-- These assets net against what the bank OWES other banks, not against the whole base, and
|
||||
the netting floors at zero — so where they exceed what is owed, nothing comes off at all.
|
||||
Said plainly, because "deducted from NDTL" was being printed on a card where no
|
||||
deduction had been made. -->
|
||||
<div class="sc-sub" [class.dn]="bankingAssetsNet">
|
||||
{{ bankingAssetsNet ? 'Reduces the base below' : 'More than we owe banks — base unchanged' }}
|
||||
</div>
|
||||
<div class="sc-change dn">↓ Deducted from NDTL</div>
|
||||
</div>
|
||||
<div class="summary-card sc-ndtl">
|
||||
<div class="sc-label">Net Demand & Time Liabilities (NDTL)</div>
|
||||
<div class="sc-value">{{ crore(ndtl) }}<span class="cr" *ngIf="ndtl !== null">Cr</span></div>
|
||||
<div class="sc-sub">After deducting banking assets</div>
|
||||
</div>
|
||||
<div class="summary-card sc-crr">
|
||||
<!-- The rate is named from the configuration, not fixed here: a card headed "CRR Required
|
||||
(3%)" while Admin holds 4% states a rate the bank is not on. -->
|
||||
<div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div>
|
||||
<div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
|
||||
<div class="sc-sub">SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}</div>
|
||||
<div class="sc-label">NDTL — the base</div>
|
||||
<div class="sc-value" [title]="money(ndtl)">{{ tile(ndtl) }}</div>
|
||||
<div class="sc-sub">Demand + time, net of bank balances</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,26 +97,33 @@
|
||||
<span class="tab-pill pill-time">02</span> Time Liabilities
|
||||
</button>
|
||||
<button class="tab-btn" [class.active]="activeTab === 'assets'" (click)="activeTab = 'assets'">
|
||||
<span class="tab-pill pill-asset">03</span> Assets with Banking System
|
||||
<span class="tab-pill pill-asset">03</span> Held with other banks
|
||||
</button>
|
||||
<button class="tab-btn" [class.active]="activeTab === 'slr'" (click)="activeTab = 'slr'">
|
||||
<span class="tab-pill pill-slr">04</span> SLR — Liquid Assets
|
||||
<span class="tab-pill pill-slr">04</span> SLR assets
|
||||
</button>
|
||||
<!-- Where the reserve is actually held, and what it holds TODAY. The four tabs above report the
|
||||
fortnight close the return is made up to; this one answers the question a treasury desk
|
||||
asks every morning — as things stand now, are we short, and which account do we cover it
|
||||
into. A close two weeks old cannot say that, because the balances have since moved. -->
|
||||
<button class="tab-btn" [class.active]="activeTab === 'held'" (click)="activeTab = 'held'">
|
||||
<span class="tab-pill pill-held">05</span> Our accounts today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Demand Liabilities Table -->
|
||||
@if (activeTab === 'demand') {
|
||||
<div class="section-desc">Amounts payable on demand — RBI Schedule II</div>
|
||||
<div class="section-desc">Money the bank must repay whenever a customer asks for it.</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sl">#</th>
|
||||
<th>Liability Item</th>
|
||||
<th>Account Code</th>
|
||||
<th class="right">Opening (₹ Cr)</th>
|
||||
<th class="right">Current (₹ Cr)</th>
|
||||
<th class="right">Change (₹ Cr)</th>
|
||||
<th>Item</th>
|
||||
<th class="right">Opening</th>
|
||||
<th class="right">Current</th>
|
||||
<th class="right">Change</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -167,17 +177,17 @@
|
||||
|
||||
<!-- Time Liabilities Table -->
|
||||
@if (activeTab === 'time') {
|
||||
<div class="section-desc">Amounts payable after a specified period — RBI Schedule III</div>
|
||||
<div class="section-desc">Money the bank repays only when the deposit matures.</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sl">#</th>
|
||||
<th>Liability Item</th>
|
||||
<th>Account Code</th>
|
||||
<th class="right">Opening (₹ Cr)</th>
|
||||
<th class="right">Current (₹ Cr)</th>
|
||||
<th class="right">Change (₹ Cr)</th>
|
||||
<th>Item</th>
|
||||
<th class="right">Opening</th>
|
||||
<th class="right">Current</th>
|
||||
<th class="right">Change</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -231,17 +241,20 @@
|
||||
|
||||
<!-- Assets with Banking System Table -->
|
||||
@if (activeTab === 'assets') {
|
||||
<div class="section-desc">Deducted from gross liabilities to arrive at NDTL — RBI Master Circular</div>
|
||||
<div class="section-desc">
|
||||
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.
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sl">#</th>
|
||||
<th>Asset Item</th>
|
||||
<th>Account Code</th>
|
||||
<th class="right">Opening (₹ Cr)</th>
|
||||
<th class="right">Current (₹ Cr)</th>
|
||||
<th class="right">Change (₹ Cr)</th>
|
||||
<th>Item</th>
|
||||
<th class="right">Opening</th>
|
||||
<th class="right">Current</th>
|
||||
<th class="right">Change</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -275,7 +288,12 @@
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
|
||||
<strong>Total Assets with Banking System</strong>
|
||||
<span class="deduct-tag">{{
|
||||
bankingAssetsNet
|
||||
? '( Deducted from NDTL )'
|
||||
: '( Netted against item I, which is smaller — no deduction )'
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="right foot-asset">{{ totalOf(bankingAssets, 'openingValue') }}</td>
|
||||
<td class="right foot-asset">{{ totalOf(bankingAssets, 'currentValue') }}</td>
|
||||
@@ -289,20 +307,18 @@
|
||||
|
||||
<!-- SLR - Liquid Assets Table -->
|
||||
@if (activeTab === 'slr') {
|
||||
<div class="section-desc">
|
||||
SLR = (Liquid Assets / NDTL) × 100 — RBI Sec 24(2A) of Banking Regulation Act, 1949
|
||||
</div>
|
||||
<div class="section-desc">Cash, bank balances and securities the bank holds against its SLR requirement.</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sl">#</th>
|
||||
<th>Liquid Asset</th>
|
||||
<th>Account Code</th>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th class="right">Opening (₹ Cr)</th>
|
||||
<th class="right">Current (₹ Cr)</th>
|
||||
<th class="right">Change (₹ Cr)</th>
|
||||
<th class="right">Opening</th>
|
||||
<th class="right">Current</th>
|
||||
<th class="right">Change</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -348,119 +364,141 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- SLR Formula -->
|
||||
<!-- How the ratio is struck. The "× 100" box that stood here was a term whose value was 100 and
|
||||
whose label was 100 — it took a quarter of the row to say that a fraction is written as a
|
||||
percentage. -->
|
||||
<div class="formula-block slr-formula">
|
||||
<div class="formula-title">SLR Computation — (Liquid Assets / NDTL) × 100</div>
|
||||
<div class="formula-title">How the SLR ratio is worked out</div>
|
||||
<div class="formula-row">
|
||||
<div class="formula-term ft-slr">
|
||||
<div class="formula-term-label">Liquid Assets</div>
|
||||
<div class="formula-term-val">₹13,238.80</div>
|
||||
<div class="formula-term-sub">6 asset categories</div>
|
||||
<div class="formula-term-label">Assets we hold</div>
|
||||
<div class="formula-term-val">{{ money(totalLiquidAssets) }}</div>
|
||||
<div class="formula-term-sub">{{ liquidAssets.length }} categories</div>
|
||||
</div>
|
||||
<div class="formula-op">÷</div>
|
||||
<div class="formula-term ft-ndtl">
|
||||
<div class="formula-term-label">NDTL</div>
|
||||
<div class="formula-term-val">₹69,221.30</div>
|
||||
<div class="formula-term-sub">Net Demand & Time</div>
|
||||
</div>
|
||||
<div class="formula-op">×</div>
|
||||
<div class="formula-term" style="border-color: #e2e8f0; background: #f8fafc">
|
||||
<div class="formula-term-label" style="color: #64748b">100</div>
|
||||
<div class="formula-term-val" style="color: #334155">100</div>
|
||||
<div class="formula-term-sub"> </div>
|
||||
<div class="formula-term-label">NDTL base</div>
|
||||
<div class="formula-term-val">{{ money(baseNdtl) }}</div>
|
||||
<div class="formula-term-sub">as at {{ baseDateStr || '—' }}</div>
|
||||
</div>
|
||||
<div class="formula-op equals">=</div>
|
||||
<div class="formula-term ft-slr-result">
|
||||
<div class="formula-term-label">Actual SLR</div>
|
||||
<div class="formula-term-label">SLR held</div>
|
||||
<div class="formula-term-val">{{ slrActualPercent }}%</div>
|
||||
<div class="formula-term-sub">Min required: {{ slrRate }}%</div>
|
||||
<div class="formula-term-sub">minimum {{ slrRate }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLR Status Bar -->
|
||||
<div class="slr-status-bar">
|
||||
<div class="slr-status-left">
|
||||
<div class="slr-status-title">SLR Compliance Status</div>
|
||||
<div class="slr-status-desc">
|
||||
Actual: {{ slrActualPercent }}% · Required: {{ slrRate }}.00% · NDTL: ₹{{
|
||||
ndtl | number: '1.2-2'
|
||||
}}
|
||||
Cr
|
||||
</div>
|
||||
</div>
|
||||
<div class="slr-status-calcs">
|
||||
<div class="slr-calc">
|
||||
<div class="slr-calc-label">SLR Required ({{ slrRate }}%)</div>
|
||||
<div class="slr-calc-val">₹{{ slrRequired | number: '1.2-2' }} Cr</div>
|
||||
</div>
|
||||
<div class="slr-calc">
|
||||
<div class="slr-calc-label">Liquid Assets Held</div>
|
||||
<div class="slr-calc-val">₹{{ totalLiquidAssets | number: '1.2-2' }} Cr</div>
|
||||
</div>
|
||||
<div class="slr-calc">
|
||||
<div class="slr-calc-label">Surplus</div>
|
||||
<div class="slr-calc-val surplus">₹{{ slrSurplus | number: '1.2-2' }} Cr</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The SLR compliance bar that used to sit here said the same three figures as the row above
|
||||
it and the bar below it — required, held, surplus, three times on one screen. It is gone;
|
||||
the compliance bar at the foot carries both ratios for every tab. -->
|
||||
}
|
||||
|
||||
<!-- NDTL Formula Block -->
|
||||
<div class="formula-block">
|
||||
<div class="formula-title">
|
||||
NDTL Computation — Formula Breakdown · Data as of {{ reportingDateStr }}
|
||||
<!-- Required against held, for both ratios. Always on screen, under whichever tab is open:
|
||||
whether the bank is short is the one thing a reader must not have to go looking for. -->
|
||||
<div class="crr-bar">
|
||||
<div class="crr-text">
|
||||
<div class="crr-title">Are we meeting the requirement?</div>
|
||||
<div class="crr-desc">
|
||||
Charged on the NDTL of {{ money(baseNdtl) }} as at {{ baseDateStr || reportingDateStr }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="formula-row">
|
||||
<div class="formula-term ft-demand">
|
||||
<div class="formula-term-label">Demand Liabilities</div>
|
||||
<div class="formula-term-val">₹{{ totalDemand | number: '1.2-2' }}</div>
|
||||
<div class="formula-term-sub">16 line items</div>
|
||||
</div>
|
||||
<div class="formula-op">+</div>
|
||||
<div class="formula-term ft-time">
|
||||
<div class="formula-term-label">Time Liabilities</div>
|
||||
<div class="formula-term-val">₹{{ totalTime | number: '1.2-2' }}</div>
|
||||
<div class="formula-term-sub">13 line items</div>
|
||||
</div>
|
||||
<div class="formula-op minus">−</div>
|
||||
<div class="formula-term ft-asset">
|
||||
<div class="formula-term-label">Assets with Banking</div>
|
||||
<div class="formula-term-val">₹{{ totalBankingAssets | number: '1.2-2' }}</div>
|
||||
<div class="formula-term-sub">3 line items</div>
|
||||
</div>
|
||||
<div class="formula-op equals">=</div>
|
||||
<div class="formula-term ft-ndtl">
|
||||
<div class="formula-term-label">NDTL</div>
|
||||
<div class="formula-term-val">₹{{ ndtl | number: '1.2-2' }}</div>
|
||||
<div class="formula-term-sub">CRR & SLR base</div>
|
||||
<!-- Required AND held, side by side. The requirement alone answers half the question: a screen
|
||||
headed "Reserve Requirements" that states ₹2.10 Cr without saying what is against it sends
|
||||
a reader to another tab to find out whether the bank is short — which is the one thing they
|
||||
opened this bar to learn. -->
|
||||
<div class="crr-calcs">
|
||||
@for (r of positionRatios(); track r.name) {
|
||||
<div class="crr-calc" [class.crr-calc--short]="!r.met">
|
||||
<!-- A ratio with no rate on record is said to have none. It used to print "SLR @ null%" and
|
||||
a surplus of nothing, which reads as a requirement met rather than one not set. -->
|
||||
<div class="crr-calc-label">{{ r.name }}{{ r.rate === null ? ' — rate not set' : ' @ ' + r.rate + '%' }}</div>
|
||||
<div class="crr-calc-val">{{ money(r.required) }}</div>
|
||||
<div class="crr-calc-held">
|
||||
held {{ money(r.maintained) }}
|
||||
<span [class.crr-short]="!r.met">
|
||||
· {{ r.met ? '+' + money(r.surplus) : 'short ' + money(r.shortfall) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="crr-calc">
|
||||
<div class="crr-calc-label">Total Reserve</div>
|
||||
<div class="crr-calc-val">{{ money(totalReserve) }}</div>
|
||||
<div class="crr-calc-held">CRR + SLR required</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CRR/SLR Bar -->
|
||||
<div class="crr-bar">
|
||||
<div class="crr-text">
|
||||
<div class="crr-title">CRR & SLR Reserve Requirements — RBI Computation on NDTL</div>
|
||||
<div class="crr-desc">
|
||||
Based on NDTL of ₹{{ ndtl | number: '1.2-2' }} Cr · Data as of {{ reportingDateStr }} (45-day lag)
|
||||
</div>
|
||||
<!-- Reserve accounts, as they stand today -->
|
||||
@if (activeTab === 'held') {
|
||||
<div class="section-desc">
|
||||
Where the cash reserve is held, with each account's balance on the business date
|
||||
<strong>{{ position?.onDate || '—' }}</strong
|
||||
>. The requirement below is charged on <strong>{{ position?.baseDate || '—' }}</strong
|
||||
>, a fortnight back — RBI's lag is a rule, so today's own liabilities settle nothing yet.
|
||||
</div>
|
||||
<div class="crr-calcs">
|
||||
<div class="crr-calc">
|
||||
<div class="crr-calc-label">CRR @ {{ crrRate }}.00%</div>
|
||||
<div class="crr-calc-val">₹{{ crrRequired | number: '1.2-2' }} Cr</div>
|
||||
|
||||
@if (position) {
|
||||
<!-- Required against held, for each ratio. Stated before the accounts because a desk reads
|
||||
the gap first and only then asks which account to move money into. -->
|
||||
<div class="held-ratios">
|
||||
@for (r of positionRatios(); track r.name) {
|
||||
<div class="held-ratio" [class.held-ratio--short]="!r.met">
|
||||
<div class="held-ratio__name">{{ r.name }}{{ r.rate === null ? '' : ' @ ' + r.rate + '%' }}</div>
|
||||
<div class="held-ratio__grid">
|
||||
<span>Required</span><strong>{{ money(r.required) }}</strong> <span>Maintained</span
|
||||
><strong>{{ money(r.maintained) }}</strong>
|
||||
<span>{{ r.met ? 'Surplus' : 'Shortfall' }}</span>
|
||||
<strong [class.held-short]="!r.met">{{ money(r.met ? r.surplus : r.shortfall) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="crr-calc">
|
||||
<div class="crr-calc-label">SLR @ {{ slrRate }}.00%</div>
|
||||
<div class="crr-calc-val">₹{{ slrRequired | number: '1.2-2' }} Cr</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sl">#</th>
|
||||
<th>Account</th>
|
||||
<th>Form 1 head</th>
|
||||
<th class="right">Balance today</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of position.accounts; track a.glAccountId) {
|
||||
<tr>
|
||||
<td class="sl">{{ $index + 1 }}</td>
|
||||
<td>
|
||||
<div class="item-name">{{ a.name }}</div>
|
||||
<div class="item-sub mono">{{ a.glCode }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ a.item }} · {{ a.head }}
|
||||
<!-- An account the return does not read is named as such. It can still be moved
|
||||
into — the money is real — but adding it to what is maintained would have
|
||||
this screen claim a compliance the filed form does not. -->
|
||||
@if (!a.countsTowardMaintained) {
|
||||
<span class="held-chip">not counted on the return</span>
|
||||
}
|
||||
</td>
|
||||
<td class="right amt">{{ money(a.balance) }}</td>
|
||||
<td class="right">
|
||||
<button class="held-move" (click)="openTransfer(a)">
|
||||
<fa-icon icon="exchange-alt"></fa-icon>
|
||||
Transfer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="crr-calc">
|
||||
<div class="crr-calc-label">Total Reserve</div>
|
||||
<div class="crr-calc-val">₹{{ totalReserve | number: '1.2-2' }} Cr</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="dialog-footer">
|
||||
|
||||
@@ -158,7 +158,10 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
||||
// ===== Summary Cards =====
|
||||
.summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
// Five cards only while each can still hold its figure. A seventy-crore NDTL printed to the
|
||||
// paise needs room, and fixed fifths cut it off at the card edge — the figures were there and
|
||||
// unreadable. Below that width the row wraps to fewer, wider cards instead.
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 14px;
|
||||
padding: 20px 28px;
|
||||
}
|
||||
@@ -167,8 +170,12 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
||||
background: $surface;
|
||||
border: 1px solid $border;
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
// 18px of side padding on a fifth of the row left the figure barely more than half the card.
|
||||
padding: 16px 14px;
|
||||
position: relative;
|
||||
// A grid item will not shrink below its content by default, which is how a long figure pushed
|
||||
// its own card wider than its column and off the edge of the dialog.
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
@@ -210,16 +217,29 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: $ink3;
|
||||
font-family: $mono;
|
||||
letter-spacing: 1px;
|
||||
// Tightened from 1px: on a five-up row the tracking alone pushed "NET DEMAND & TIME LIABILITIES
|
||||
// (NDTL)" onto a third line and squeezed the figure beneath it.
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
// Wraps rather than clipping — a heading that reads "ASSETS WITH BANKIN" names nothing.
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sc-value {
|
||||
font-size: 20px;
|
||||
// Sized to the card rather than fixed: a figure that outgrows its box is worse than a smaller
|
||||
// one, because the digits that fall off the right are the ones that distinguish it. Floors at
|
||||
// 14px so a long figure shrinks rather than wraps mid-number.
|
||||
font-size: clamp(14px, 1.45vw, 20px);
|
||||
font-weight: 600;
|
||||
color: $ink;
|
||||
font-family: $mono;
|
||||
// Figures are read by lining their digits up; a proportional fallback would stagger them.
|
||||
font-variant-numeric: tabular-nums;
|
||||
// A rupee figure never breaks across lines — "₹6,35,05," over "967.50" reads as two numbers.
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cr {
|
||||
@@ -232,12 +252,16 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
||||
.sc-change {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sc-sub {
|
||||
font-size: 11px;
|
||||
color: $ink3;
|
||||
margin-top: 4px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.up {
|
||||
@@ -869,3 +893,112 @@ $slr-b: #bae6fd;
|
||||
line-height: 1.6;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* ── Reserve accounts, as they stand today ── */
|
||||
.pill-held {
|
||||
background: #0b5394;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Required against held, per ratio — read before the accounts, because a desk asks how big the
|
||||
gap is before asking which account to cover it from. */
|
||||
.held-ratios {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px 20px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.held-ratio {
|
||||
flex: 1 1 260px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #137333;
|
||||
border-radius: 8px;
|
||||
background: #f8faf9;
|
||||
}
|
||||
|
||||
/* A ratio that is not met is the thing this tab exists to surface. */
|
||||
.held-ratio--short {
|
||||
border-left-color: #c5221f;
|
||||
background: #fdf6f6;
|
||||
}
|
||||
|
||||
.held-ratio__name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #5f6368;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.held-ratio__grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 14px;
|
||||
font-size: 13px;
|
||||
|
||||
span {
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
strong {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.held-short {
|
||||
color: #c5221f;
|
||||
}
|
||||
|
||||
/* An account the filed return does not read. Named rather than hidden: the money is real and can
|
||||
be moved, but it settles nothing on the form. */
|
||||
.held-chip {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
background: #fff4e5;
|
||||
color: #b06000;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.held-move {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #0b5394;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #0b5394;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: #0b5394;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* What is held against each requirement, under the requirement itself. */
|
||||
.crr-calc-held {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: $ink3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A ratio that is not met — on a compliance bar this is the one thing that must not be missed. */
|
||||
.crr-calc--short {
|
||||
border-color: $red;
|
||||
}
|
||||
|
||||
.crr-short,
|
||||
.sc-short {
|
||||
color: $red;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule, DecimalPipe } from '@angular/common';
|
||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatDialog, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
|
||||
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||
import { ReserveTransferDialogComponent } from './reserve-transfer-dialog.component';
|
||||
|
||||
/** One account under a head, at the base date and the reporting date. */
|
||||
interface CrrSlrRow {
|
||||
@@ -31,6 +33,47 @@ interface CrrSlrRow {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** One reserve account, with what it holds on the business date. */
|
||||
interface ReserveAccount {
|
||||
glAccountId: number;
|
||||
glCode: string;
|
||||
name: string;
|
||||
balance: number;
|
||||
/** The Form 1 item the account answers — V, VI(a), VI(b), VI(c). */
|
||||
item: string;
|
||||
head: string;
|
||||
/**
|
||||
* Whether the filed return reads this balance.
|
||||
*
|
||||
* An account the bank holds but has not mapped is still somewhere money can be moved, so it is
|
||||
* listed — but counting it toward what is maintained would have this screen claim a compliance
|
||||
* the return does not.
|
||||
*/
|
||||
countsTowardMaintained: boolean;
|
||||
}
|
||||
|
||||
/** One ratio's standing: what it needs, what it has, and which way the gap runs. */
|
||||
interface ReserveRatio {
|
||||
required: number;
|
||||
maintained: number;
|
||||
surplus: number;
|
||||
/** Positive — a shortfall is an amount to cover, not a negative surplus. */
|
||||
shortfall: number;
|
||||
met: boolean;
|
||||
}
|
||||
|
||||
/** The reserve position as it stands on the business date. */
|
||||
interface ReservePosition {
|
||||
onDate: string;
|
||||
/** The close the requirement is charged on — a fortnight back, by RBI's lag rule. */
|
||||
baseDate: string;
|
||||
crrPercentage: number | null;
|
||||
slrPercentage: number | null;
|
||||
crr: ReserveRatio;
|
||||
slr: ReserveRatio;
|
||||
accounts: ReserveAccount[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-crr-slr-dialog',
|
||||
standalone: true,
|
||||
@@ -49,8 +92,18 @@ interface CrrSlrRow {
|
||||
export class CrrSlrDialogComponent implements OnInit {
|
||||
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
|
||||
private rbi = inject(RbiCircularsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
activeTab: 'demand' | 'time' | 'assets' | 'slr' = 'demand';
|
||||
activeTab: 'demand' | 'time' | 'assets' | 'slr' | 'held' = 'demand';
|
||||
|
||||
/**
|
||||
* The reserve position on the business date — null until it loads, or if it cannot be read.
|
||||
*
|
||||
* Separate from the return the other tabs show: those report a fortnight close, this reports
|
||||
* where the money is now.
|
||||
*/
|
||||
position: ReservePosition | null = null;
|
||||
|
||||
/** The date the figures were struck at, as the return itself reports it. */
|
||||
reportingDateStr = '';
|
||||
@@ -82,7 +135,23 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
totalDemand: number | null = null;
|
||||
totalTime: number | null = null;
|
||||
totalBankingAssets: number | null = null;
|
||||
/**
|
||||
* Whether item III was actually deducted from the base.
|
||||
*
|
||||
* It nets against item I rather than against the whole base, and the netting floors at zero — so
|
||||
* a bank holding more with other banks than it owes them has nothing deducted at all.
|
||||
*/
|
||||
bankingAssetsNet = false;
|
||||
/** Item IV(a) — the bank's own net liabilities on the date being reported. */
|
||||
ndtl: number | null = null;
|
||||
/**
|
||||
* Item IV(b) — the base a fortnight back, which both ratios are actually charged on.
|
||||
*
|
||||
* A separate figure from `ndtl` above and not interchangeable with it: the requirement is struck
|
||||
* on this one, so a ratio or a tile that quotes the other states a percentage of something the
|
||||
* requirement was never computed from.
|
||||
*/
|
||||
baseNdtl: number | null = null;
|
||||
crrRate: number | null = null;
|
||||
slrRate: number | null = null;
|
||||
crrRequired: number | null = null;
|
||||
@@ -98,7 +167,7 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
* A dash and a zero say different things — "not reported" against "reported as nil" — and on a
|
||||
* regulatory screen the difference decides whether anyone goes looking for the missing mapping.
|
||||
*/
|
||||
crore(value: number | null): string {
|
||||
rupees(value: number | null): string {
|
||||
if (value === null) {
|
||||
return '—';
|
||||
}
|
||||
@@ -106,9 +175,19 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// The return is reported in rupees; this screen reads in crore, as a dashboard tile does.
|
||||
const toCrore = (v: number | null | undefined): number | null =>
|
||||
v === null || v === undefined ? null : +(v / 1e7).toFixed(2);
|
||||
// Today's position, alongside the return below. The two answer different questions and are
|
||||
// fetched separately: the return reports a fortnight close, this reports the balances as they
|
||||
// stand on the business date, which is what a desk can still act on.
|
||||
this.rbi.getReservePosition().subscribe({
|
||||
next: (data: ReservePosition) => (this.position = data),
|
||||
// A position that cannot be read leaves the other four tabs working. The tab simply shows
|
||||
// nothing rather than taking the whole dialog down with it.
|
||||
error: () => (this.position = null)
|
||||
});
|
||||
|
||||
// Figures stay in rupees. They were rounded to crore until now, which on a bank this size threw
|
||||
// away everything that tells one fortnight from the next — a seventy-crore NDTL and the one a
|
||||
// fortnight later both read "₹70 Cr".
|
||||
|
||||
// The statutory Form 1, not the working report. Form 1 is filed twice a month — at the 15th and
|
||||
// at the month end — and this screen should show the close the bank last filed, not a position
|
||||
@@ -139,27 +218,66 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
// Part A's own lines, read by the Roman numeral each carries. Read from the return rather
|
||||
// than recomputed, so this screen and the filed form cannot come to disagree.
|
||||
const lines: any[] = data?.partA?.lines ?? [];
|
||||
// Matched without regard to case: the form prints its heads in capitals — "(A) DEMAND
|
||||
// LIABILITIES :" — and matching them as written left every tile reading a dash while the
|
||||
// figures sat in the response.
|
||||
// Matched without regard to case, and only against lines that actually carry figures. The
|
||||
// form heads item I with "(a) Demand Liabilities:" — a caption with no values — directly
|
||||
// above item II's "(A) DEMAND LIABILITIES :", which holds the figure. Matching on text
|
||||
// alone found the caption first and read a dash off it.
|
||||
const at = (startsWith: string): number | null => {
|
||||
const line = lines.find((l: any) => String(l?.label ?? '').startsWith(startsWith));
|
||||
const want = startsWith.toUpperCase();
|
||||
const line = lines.find(
|
||||
(l: any) =>
|
||||
Array.isArray(l?.values) &&
|
||||
String(l?.label ?? '')
|
||||
.toUpperCase()
|
||||
.startsWith(want)
|
||||
);
|
||||
return line?.values?.[latest] ?? null;
|
||||
};
|
||||
|
||||
this.totalDemand = toCrore(at('(A) Demand liabilities'));
|
||||
this.totalTime = toCrore(at('(B) Time liabilities'));
|
||||
this.totalBankingAssets = toCrore(at('TOTAL OF III'));
|
||||
// IV(b) is the base the ratios are actually charged on — the figure behind the requirement,
|
||||
// not the bank's liabilities on the day being reported.
|
||||
this.ndtl = toCrore(at('IV. b)'));
|
||||
this.crrRequired = toCrore(data?.partB?.required?.[latest]);
|
||||
this.slrRequired = toCrore(data?.partC?.required?.[latest]);
|
||||
this.totalLiquidAssets = toCrore(data?.partC?.maintained?.[latest]);
|
||||
// Item II's own two lines, matched including the trailing colon the form prints. Item I
|
||||
// carries a "(b) Time Liabilities to the banking system" of its own — a different figure,
|
||||
// and the one a looser match found first.
|
||||
this.totalDemand = at('(A) DEMAND LIABILITIES :');
|
||||
this.totalTime = at('(B) TIME LIABILITIES :');
|
||||
this.totalBankingAssets = at('TOTAL OF III');
|
||||
// Whether item III actually came off the base. The form nets it against item I — the
|
||||
// liabilities OWED TO the banking system — and floors that netting at zero: where the
|
||||
// bank's assets with other banks exceed what it owes them, the excess is NOT deducted and
|
||||
// the base is item II alone. Stating "deducted from NDTL" in that case names a subtraction
|
||||
// the return did not make, and a reader checking the arithmetic finds it does not work.
|
||||
const liabilitiesToBanks = at('TOTAL OF I');
|
||||
this.bankingAssetsNet =
|
||||
liabilitiesToBanks !== null &&
|
||||
this.totalBankingAssets !== null &&
|
||||
liabilitiesToBanks > this.totalBankingAssets;
|
||||
// Two different figures, and the screen needs both.
|
||||
//
|
||||
// IV(a) is the bank's OWN net liabilities on the reporting date — what the demand, time and
|
||||
// banking-asset tiles add up to, so it is the one the formula row must end at. IV(b) is the
|
||||
// base a fortnight back, which is what the requirement is charged on.
|
||||
//
|
||||
// Showing IV(b) as the result of the formula was arithmetic that did not work: three terms
|
||||
// struck on one date reaching a figure struck on another, off by whatever the liabilities
|
||||
// had moved in between.
|
||||
this.ndtl = at('IV. a)');
|
||||
this.baseNdtl = at('IV. b)');
|
||||
this.crrRequired = data?.partB?.required?.[latest] ?? null;
|
||||
this.slrRequired = data?.partC?.required?.[latest] ?? null;
|
||||
this.totalLiquidAssets = data?.partC?.maintained?.[latest] ?? null;
|
||||
|
||||
if (this.slrRequired !== null && this.totalLiquidAssets !== null) {
|
||||
this.slrSurplus = +(this.totalLiquidAssets - this.slrRequired).toFixed(2);
|
||||
}
|
||||
if (this.ndtl) {
|
||||
// Struck on the base, not on today's own liabilities: the requirement below it is charged
|
||||
// on the base, and a ratio measured against a different denominator from the one the
|
||||
// requirement uses cannot be compared with it — "33.62% against a minimum of 18%" would be
|
||||
// two percentages of two different things.
|
||||
if (this.baseNdtl) {
|
||||
this.slrActualPercent =
|
||||
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.ndtl) * 100).toFixed(2);
|
||||
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.baseNdtl) * 100).toFixed(2);
|
||||
}
|
||||
if (this.crrRequired !== null && this.slrRequired !== null) {
|
||||
this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2);
|
||||
@@ -209,13 +327,20 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
// The form's own wording, with its numbering trimmed off the front — the table has a column
|
||||
// of its own for that.
|
||||
name: label.replace(/^\s*\(?[a-zA-Z0-9]+\)?[.)]?\s*/, '').trim() || label.trim(),
|
||||
sub: line.configKey ? String(line.configKey).replace(/_/g, ' ') : 'Derived from the form',
|
||||
code: line.configKey ?? '—',
|
||||
opening: this.crore(opening),
|
||||
current: this.crore(current),
|
||||
// The form's own numbering — I(a)(ii), II(A), VII(c) — rather than the configuration key
|
||||
// behind it. A reader checking this screen against the filed return looks for the item
|
||||
// number; FORM1_I_A_II_OTHER_DEMAND_BS is the name of a database row and means nothing to
|
||||
// them.
|
||||
sub: '',
|
||||
code: (label.match(/^\s*\(?([IVXivx]+|[A-Za-z0-9]+)\)?[.)]?/) ?? [
|
||||
,
|
||||
'—'
|
||||
])[1] ?? '—',
|
||||
opening: this.rupees(opening),
|
||||
current: this.rupees(current),
|
||||
openingValue: opening,
|
||||
currentValue: current,
|
||||
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`,
|
||||
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`,
|
||||
changeDir: change === null ? 'flat' : change >= 0 ? 'up' : 'dn',
|
||||
// A head with no GL mapped reports nothing, and the form says so rather than reporting nil.
|
||||
status: line.configured === false ? 'Not mapped' : 'Active'
|
||||
@@ -235,7 +360,7 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
*/
|
||||
totalOf(rows: CrrSlrRow[], column: 'openingValue' | 'currentValue'): string {
|
||||
const values = rows.map((r) => r[column]).filter((v): v is number => v !== null);
|
||||
return values.length ? this.crore(values.reduce((a, b) => a + b, 0)) : '—';
|
||||
return values.length ? this.rupees(values.reduce((a, b) => a + b, 0)) : '—';
|
||||
}
|
||||
|
||||
/** The movement across a tab, between the two closes the form reports. */
|
||||
@@ -246,7 +371,7 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
return '—';
|
||||
}
|
||||
const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0);
|
||||
return `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`;
|
||||
return `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`;
|
||||
}
|
||||
|
||||
/** Fills the four tabs from one reading of the form. */
|
||||
@@ -257,16 +382,148 @@ export class CrrSlrDialogComponent implements OnInit {
|
||||
};
|
||||
// Matched on how the form labels each head, not on a fixed list of account names: a bank that
|
||||
// maps a new account to a head gets it on the right tab without this screen being changed.
|
||||
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit'));
|
||||
this.timeLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'time liabilit') && !has(l, 'demand and time'));
|
||||
// III — what the banking system owes this bank, which nets off the liability base.
|
||||
this.bankingAssets = this.rowsFrom(lines, latest, (l) =>
|
||||
has(l, 'current account balances with sbi', 'balances in all accounts', 'money at call', 'advances and any other')
|
||||
// Item II only — what the bank owes its own depositors. Item I's lines are worded the same way
|
||||
// but are owed to the banking system, and the form nets them against item III rather than
|
||||
// adding them here, so a tab headed "payable on demand" that carried both would double-count
|
||||
// the one and misstate the other.
|
||||
const toBankingSystem = (l: string): boolean => has(l, 'to the banking system', 'banking system');
|
||||
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit') && !toBankingSystem(l));
|
||||
this.timeLiabilities = this.rowsFrom(
|
||||
lines,
|
||||
latest,
|
||||
(l) => has(l, 'time liabilit') && !has(l, 'demand and time') && !toBankingSystem(l)
|
||||
);
|
||||
// V, VI and VII — the cash, balances and securities the two ratios are met in.
|
||||
this.liquidAssets = this.rowsFrom(lines, latest, (l) =>
|
||||
has(l, 'cash on hand', 'reserve bank of india', 'co-operative bank', 'government securities')
|
||||
// III — what the banking system owes this bank, which nets off the liability base. Matched on
|
||||
// the words the form actually prints: its item III(a) reads "TOTAL OF CREDIT BALANCES IN
|
||||
// CURRENT ACCOUNT maintained with S.B.I...", which the shorter phrases this looked for never
|
||||
// appeared in, so the tab came out empty while its total showed a figure.
|
||||
this.bankingAssets = this.rowsFrom(
|
||||
lines,
|
||||
latest,
|
||||
(l) =>
|
||||
has(
|
||||
l,
|
||||
'credit balances in current account',
|
||||
'other assets with the banking system',
|
||||
'balances in all accounts',
|
||||
'money at call',
|
||||
'advances & any other'
|
||||
) &&
|
||||
// Item I(a)(i) is worded almost identically but is the opposite side of the ledger: what
|
||||
// the banking system holds WITH this bank, a liability. On a tab headed "deducted from
|
||||
// gross liabilities" it would be read as an asset and netted the wrong way.
|
||||
!has(l, 'maintained with the co-operative bank by state')
|
||||
);
|
||||
// V, VI and VII — the cash, balances and securities the two ratios are met in. Item I's own
|
||||
// SBI line is excluded: it is a liability TO the banking system, and listing it among the
|
||||
// assets held against a ratio reads as something the bank can count toward it.
|
||||
this.liquidAssets = this.rowsFrom(
|
||||
lines,
|
||||
latest,
|
||||
(l) =>
|
||||
has(l, 'cash on hand', 'reserve bank of india', 'co.op.bank', 'co-operative bank', 'govt. security') &&
|
||||
!has(l, 'maintained with the co-operative bank by state')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The two ratios as rows, so the template states them once rather than twice over.
|
||||
*/
|
||||
positionRatios(): {
|
||||
name: string;
|
||||
rate: number | null;
|
||||
required: number;
|
||||
maintained: number;
|
||||
surplus: number;
|
||||
shortfall: number;
|
||||
met: boolean;
|
||||
}[] {
|
||||
if (!this.position) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ name: 'CRR', rate: this.position.crrPercentage, ...this.position.crr },
|
||||
{ name: 'SLR', rate: this.position.slrPercentage, ...this.position.slr }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A reserve figure in rupees, to the paisa.
|
||||
*
|
||||
* Not rounded like the table cells above: a desk covering a shortfall transfers an exact amount, and
|
||||
* "₹0.37 Cr" is not an amount anyone can send.
|
||||
*/
|
||||
money(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
return `₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A figure for a summary tile, scaled so it fits however large the bank grows.
|
||||
*
|
||||
* Tiles are read for order of magnitude, so they carry whole rupees up to a crore and switch to
|
||||
* crore above it: ₹70,10,45,062 is nineteen characters and overflows its card, where ₹70.10 Cr
|
||||
* is seven and says the same thing to anyone glancing at it. The exact amount, to the paise, is
|
||||
* on the tables below and on the reserve-accounts tab — which is where anyone acting on a figure
|
||||
* rather than scanning it goes.
|
||||
*
|
||||
* Scaled rather than merely shrunk, because a fixed format only ever fits the balances the bank
|
||||
* happens to hold today. This one holds at four or five characters whether the figure is lakhs
|
||||
* or lakh-crore, so a bank that grows ten-fold does not come back to a row of clipped tiles.
|
||||
*/
|
||||
tile(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
const abs = Math.abs(value);
|
||||
const sign = value < 0 ? '-' : '';
|
||||
// Indian scale: a crore is 1e7, a lakh 1e5. Below a lakh the whole rupee figure is already
|
||||
// short enough that scaling it would hide detail for nothing.
|
||||
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')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move money into or out of a reserve account.
|
||||
*
|
||||
* The entry posts through the platform's own journal command, so it goes to maker-checker like
|
||||
* any other entry against the ledger — the money is real, and a transfer that settles a statutory
|
||||
* requirement is not a thing one person should be able to post alone.
|
||||
*/
|
||||
openTransfer(account: ReserveAccount): void {
|
||||
const ref = this.dialog.open(ReserveTransferDialogComponent, {
|
||||
data: {
|
||||
from: account,
|
||||
// Only the reserve accounts. A transfer out of the cash reserve into something that does
|
||||
// not count toward it would quietly reduce what is maintained, and this screen exists to
|
||||
// keep that figure up.
|
||||
accounts: this.position?.accounts ?? [],
|
||||
// The entry posts against the user's own office — the one whose books hold these accounts.
|
||||
// Falls back to head office, which Fineract seeds as 1, rather than failing to open.
|
||||
officeId: this.auth.getCredentials()?.officeId ?? 1,
|
||||
currencyCode: 'INR'
|
||||
},
|
||||
autoFocus: false
|
||||
});
|
||||
|
||||
ref.afterClosed().subscribe((posted: boolean) => {
|
||||
if (posted) {
|
||||
// The balances on screen are now a step behind the ledger. Read again rather than adjusting
|
||||
// them here: the entry may have gone to maker-checker and not yet moved anything, and a
|
||||
// screen that assumed it had would show a position the bank is not in.
|
||||
this.rbi.getReservePosition().subscribe({
|
||||
next: (data: ReservePosition) => (this.position = data),
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="rt">
|
||||
<div class="rt-head">
|
||||
<h2 class="rt-title">Transfer between reserve accounts</h2>
|
||||
<button class="rt-x" (click)="close()" aria-label="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The two accounts, the way a desk thinks about them: money leaves one and arrives in the
|
||||
other. The ledger's debit and credit are worked out when the entry posts, because asking a
|
||||
treasury user which side to credit in order to move their own money is asking them to do the
|
||||
ledger's job. -->
|
||||
<div class="rt-from">
|
||||
<div class="rt-field-label">From</div>
|
||||
<div class="rt-acct">
|
||||
<div class="rt-acct-name">{{ from.name }}</div>
|
||||
<div class="rt-acct-sub">{{ from.glCode }} · {{ from.item }} {{ from.head }}</div>
|
||||
</div>
|
||||
<div class="rt-bal">
|
||||
<span class="rt-bal-label">Balance</span>
|
||||
<span class="rt-bal-val">{{ money(from.balance) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rt-arrow"><fa-icon icon="arrow-down"></fa-icon></div>
|
||||
|
||||
<div class="rt-row">
|
||||
<label class="rt-field-label" for="rt-to">To</label>
|
||||
<select id="rt-to" class="rt-input" [(ngModel)]="toAccountId">
|
||||
<option [ngValue]="null">Choose the account to receive it</option>
|
||||
@for (a of destinations; track a.glAccountId) {
|
||||
<option [ngValue]="a.glAccountId">{{ a.glCode }} · {{ a.name }} — {{ money(a.balance) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="rt-row">
|
||||
<label class="rt-field-label" for="rt-amt">Amount to transfer</label>
|
||||
<div class="rt-amount-wrap">
|
||||
<span class="rt-rupee">₹</span>
|
||||
<input
|
||||
id="rt-amt"
|
||||
class="rt-input rt-amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
[(ngModel)]="amount"
|
||||
/>
|
||||
</div>
|
||||
<!-- An account cannot send more than it holds: the ledger would carry a negative balance on a
|
||||
bank account, which is not a position that exists. Said as the amount is typed rather than
|
||||
on submit, so the figure is corrected before anyone commits to it. -->
|
||||
@if (overdrawn) {
|
||||
<div class="rt-warn">More than {{ from.name }} holds. It has {{ money(from.balance) }}.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- What each account would hold once this posts. A transfer is made to reach a balance, so the
|
||||
balance it reaches is the thing worth showing before it is sent. -->
|
||||
@if (to && (amount ?? 0) > 0 && !overdrawn) {
|
||||
<div class="rt-after">
|
||||
<div class="rt-after-row">
|
||||
<span>{{ from.name }}</span>
|
||||
<span
|
||||
>{{ money(from.balance) }} → <strong>{{ money(fromAfter) }}</strong></span
|
||||
>
|
||||
</div>
|
||||
<div class="rt-after-row">
|
||||
<span>{{ to.name }}</span>
|
||||
<span
|
||||
>{{ money(to.balance) }} → <strong>{{ money(toAfter) }}</strong></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="rt-row">
|
||||
<label class="rt-field-label" for="rt-ref">Reference <span class="rt-opt">optional</span></label>
|
||||
<input id="rt-ref" class="rt-input" type="text" placeholder="NEFT / RTGS reference" [(ngModel)]="reference" />
|
||||
</div>
|
||||
|
||||
<div class="rt-row">
|
||||
<label class="rt-field-label" for="rt-note">Note <span class="rt-opt">optional</span></label>
|
||||
<input
|
||||
id="rt-note"
|
||||
class="rt-input"
|
||||
type="text"
|
||||
placeholder="Why the money is being moved"
|
||||
[(ngModel)]="comments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- What this does and does not do. The entry is the bank's record of the movement; the money
|
||||
reaches the other bank when the desk sends the NEFT or RTGS. A screen that posted silently
|
||||
would leave someone believing the funds had moved. -->
|
||||
<div class="rt-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
This records the movement in the ledger and goes for approval. Send the NEFT or RTGS separately — the money
|
||||
does not move until you do.
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="rt-error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
{{ error }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="rt-actions">
|
||||
<button mat-button (click)="close()">Cancel</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
|
||||
{{ submitting ? 'Posting…' : 'Post transfer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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/.
|
||||
*/
|
||||
|
||||
$ink: #202124;
|
||||
$ink2: #3c4043;
|
||||
$ink3: #5f6368;
|
||||
$border: #e2e8f0;
|
||||
$primary: #0b5394;
|
||||
$red: #c5221f;
|
||||
$amber: #b06000;
|
||||
$surface: #fff;
|
||||
$mono: 'Roboto Mono', 'Courier New', monospace;
|
||||
|
||||
.rt {
|
||||
padding: 20px 24px 16px;
|
||||
min-width: 420px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.rt-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.rt-title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: $ink;
|
||||
}
|
||||
|
||||
.rt-x {
|
||||
border: none;
|
||||
background: none;
|
||||
color: $ink3;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
|
||||
&:hover {
|
||||
color: $ink;
|
||||
}
|
||||
}
|
||||
|
||||
.rt-field-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: $ink3;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rt-opt {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: $ink3;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
// The source account — fixed, since the dialog is opened from its row, so it reads as a statement
|
||||
// rather than as another field to fill in.
|
||||
.rt-from {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.rt-acct-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $ink;
|
||||
}
|
||||
|
||||
.rt-acct-sub {
|
||||
font-size: 11px;
|
||||
color: $ink3;
|
||||
font-family: $mono;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rt-bal {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed $border;
|
||||
}
|
||||
|
||||
.rt-bal-label {
|
||||
font-size: 11px;
|
||||
color: $ink3;
|
||||
}
|
||||
|
||||
.rt-bal-val {
|
||||
font-family: $mono;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $ink;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rt-arrow {
|
||||
text-align: center;
|
||||
color: $primary;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.rt-row {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rt-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: $ink2;
|
||||
background: $surface;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
// The amount carries the rupee inside the field, so the figure is read as money without the sign
|
||||
// being something anyone has to type.
|
||||
.rt-amount-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rt-rupee {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: $ink3;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rt-amount {
|
||||
padding-left: 26px;
|
||||
font-family: $mono;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rt-warn {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
// What each account would hold once this posts — a transfer is made to reach a balance, so the
|
||||
// balance it reaches belongs on screen before it is sent.
|
||||
.rt-after {
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #cfe3d4;
|
||||
border-radius: 8px;
|
||||
background: #f1f8f3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rt-after-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: $ink2;
|
||||
|
||||
& + & {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-family: $mono;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
// What the entry does and does not do: the money moves when the desk sends the NEFT, not when this
|
||||
// posts, and a dialog that stayed silent would leave someone believing otherwise.
|
||||
.rt-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff8e6;
|
||||
color: $amber;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.rt-error {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fdecea;
|
||||
color: $red;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.rt-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
|
||||
|
||||
/** A reserve account as the position screen lists it. */
|
||||
export interface TransferAccount {
|
||||
glAccountId: number;
|
||||
glCode: string;
|
||||
name: string;
|
||||
balance: number;
|
||||
item: string;
|
||||
head: string;
|
||||
}
|
||||
|
||||
/** What the dialog is opened with: the account clicked, and everything it may move to. */
|
||||
export interface ReserveTransferData {
|
||||
from: TransferAccount;
|
||||
accounts: TransferAccount[];
|
||||
/** The office the entry posts against, and the date it posts on. */
|
||||
officeId: number;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves money between the accounts the cash reserve is held in.
|
||||
*
|
||||
* The entry posts through the platform's own journal command, so it goes to maker-checker like any
|
||||
* other entry against the ledger. That is deliberate: the amounts settle a statutory requirement,
|
||||
* and a transfer that changes whether the bank is compliant is not something one person should be
|
||||
* able to post alone.
|
||||
*
|
||||
* It records the movement; it does not make it. The money reaches the other bank when the treasury
|
||||
* desk sends the NEFT or RTGS, and this entry is the bank's own record of having done so — which is
|
||||
* why the dialog says as much before it posts.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-reserve-transfer-dialog',
|
||||
standalone: true,
|
||||
templateUrl: './reserve-transfer-dialog.component.html',
|
||||
styleUrls: ['./reserve-transfer-dialog.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ReserveTransferDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<ReserveTransferDialogComponent>);
|
||||
private rbi = inject(RbiCircularsService);
|
||||
readonly data: ReserveTransferData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
/** The account money leaves. Fixed — the dialog is opened from its row. */
|
||||
readonly from: TransferAccount;
|
||||
/** Everything it can move to: the other reserve accounts, never itself. */
|
||||
readonly destinations: TransferAccount[];
|
||||
|
||||
toAccountId: number | null = null;
|
||||
amount: number | null = null;
|
||||
reference = '';
|
||||
comments = '';
|
||||
|
||||
submitting = false;
|
||||
error = '';
|
||||
|
||||
constructor() {
|
||||
this.from = this.data.from;
|
||||
this.destinations = (this.data.accounts ?? []).filter((a) => a.glAccountId !== this.data.from.glAccountId);
|
||||
}
|
||||
|
||||
/** The account chosen to receive the money, while one is chosen. */
|
||||
get to(): TransferAccount | null {
|
||||
return this.destinations.find((a) => a.glAccountId === this.toAccountId) ?? null;
|
||||
}
|
||||
|
||||
/** What the source would hold once this posts — the figure that decides whether it may. */
|
||||
get fromAfter(): number {
|
||||
return this.from.balance - (this.amount ?? 0);
|
||||
}
|
||||
|
||||
get toAfter(): number {
|
||||
return (this.to?.balance ?? 0) + (this.amount ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the amount can actually be sent.
|
||||
*
|
||||
* An account cannot send more than it holds: the ledger would carry a negative balance on a cash
|
||||
* or bank account, which is not a position that exists.
|
||||
*/
|
||||
get overdrawn(): boolean {
|
||||
return this.amount !== null && this.amount > this.from.balance;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return !this.submitting && this.toAccountId !== null && (this.amount ?? 0) > 0 && !this.overdrawn;
|
||||
}
|
||||
|
||||
money(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
return `₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the transfer as a single journal entry.
|
||||
*
|
||||
* These are asset accounts, so the one money LEAVES is credited and the one it arrives in is
|
||||
* debited — the opposite of how the screen words it, and deliberately so: a treasury user thinks
|
||||
* "send from here, receive there", and making them think in debits and credits to move their own
|
||||
* money would be asking them to do the ledger's job.
|
||||
*/
|
||||
submit(): void {
|
||||
if (!this.canSubmit || !this.to) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.error = '';
|
||||
|
||||
// The reserve endpoint, not the manual journal entry one: that is turned off at the HTTP edge
|
||||
// on this installation. The sides are named from and to, as a desk thinks of them; the debit
|
||||
// and credit are worked out on the server, where the accounts can also be checked.
|
||||
const transfer = {
|
||||
fromGlAccountId: this.from.glAccountId,
|
||||
toGlAccountId: this.to.glAccountId,
|
||||
amount: this.amount as number,
|
||||
officeId: this.data.officeId,
|
||||
reference: this.reference || undefined,
|
||||
comments:
|
||||
this.comments || `Reserve transfer: ${this.from.glCode} ${this.from.name} to ${this.to.glCode} ${this.to.name}`
|
||||
};
|
||||
|
||||
this.rbi.transferReserve(transfer).subscribe({
|
||||
next: () => this.dialogRef.close(true),
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
// The platform's own message, not a rewritten one: it names the rule that stopped the
|
||||
// entry — a closed accounting period, a disabled account — and a generic "could not post"
|
||||
// sends the user to look for a fault that has already been explained.
|
||||
this.error =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
'The transfer could not be posted.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,18 @@
|
||||
>
|
||||
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
|
||||
</div>
|
||||
@if (crrSlr.crrGap) {
|
||||
<div class="metric-gap" [class.metric-gap--short]="crrSlr.crrGap.short">{{ crrSlr.crrGap.text }}</div>
|
||||
}
|
||||
<div class="metric-row">
|
||||
<span class="metric-label"
|
||||
>SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span
|
||||
>
|
||||
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
|
||||
</div>
|
||||
@if (crrSlr.slrGap) {
|
||||
<div class="metric-gap" [class.metric-gap--short]="crrSlr.slrGap.short">{{ crrSlr.slrGap.text }}</div>
|
||||
}
|
||||
</div>
|
||||
<!-- "Within Limits" is a compliance statement, so it is made only where the return actually
|
||||
says so. Where it cannot compute, the card says that instead of passing the bank. -->
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -144,15 +144,15 @@
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="ip-field ip-field--third">
|
||||
<mat-label>PAN</mat-label>
|
||||
<input matInput formControlName="pan" />
|
||||
<input matInput formControlName="pan" mifosxRestrictInput="pan" style="text-transform: uppercase" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="ip-field ip-field--third">
|
||||
<mat-label>TAN</mat-label>
|
||||
<input matInput formControlName="tan" />
|
||||
<input matInput formControlName="tan" mifosxRestrictInput="tan" style="text-transform: uppercase" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="ip-field ip-field--third">
|
||||
<mat-label>GSTIN</mat-label>
|
||||
<input matInput formControlName="gstin" />
|
||||
<input matInput formControlName="gstin" mifosxRestrictInput="gstin" style="text-transform: uppercase" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -104,6 +104,26 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Reserve Fund Configuration -->
|
||||
<div>
|
||||
<a
|
||||
class="org-card"
|
||||
[routerLink]="['reserve-fund-config']"
|
||||
*mifosxHasPermission="'READ_PROFITAPPROPRIATIONCONFIG'"
|
||||
>
|
||||
<div class="org-card__icon org-card__icon--amber">
|
||||
<i class="fas fa-piggy-bank"></i>
|
||||
</div>
|
||||
<div class="org-card__body">
|
||||
<h3 class="org-card__title">Reserve Fund Configuration</h3>
|
||||
<p class="org-card__desc">
|
||||
Reserve funds, their GL accounts and the share of year-end net profit each receives
|
||||
</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right org-card__arrow"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- OSS 1 GL Configuration -->
|
||||
<div #oss1GlConfig>
|
||||
<a class="org-card" [routerLink]="['oss1-gl-config']" *mifosxHasPermission="'READ_RBIRESERVECONFIG'">
|
||||
|
||||
@@ -1339,4 +1339,38 @@ export class OrganizationService {
|
||||
deleteRbiReserveRate(rateId: number): Observable<any> {
|
||||
return this.http.delete(`/rbireserveconfig/rate/${rateId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<any>} `{ 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<any> {
|
||||
return this.http.get('/profitappropriationconfig');
|
||||
}
|
||||
|
||||
/** @returns {Observable<any>} `{ glAccountOptions[] }` — postable equity and liability accounts. */
|
||||
getProfitAppropriationConfigTemplate(): Observable<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
return this.http.post('/profitappropriationconfig/posting', { financialYear, note });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="rrc-container">
|
||||
<div class="rrc-header">
|
||||
<button mat-stroked-button [routerLink]="['/organization']" class="rrc-back">
|
||||
<i class="fas fa-arrow-left"></i> Organization
|
||||
</button>
|
||||
<h2 class="rrc-header__title">Reserve Fund Configuration</h2>
|
||||
</div>
|
||||
<p class="rrc-sub">
|
||||
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.
|
||||
</p>
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="rrc-alert rrc-alert--error">{{ errorMsg }}</div>
|
||||
}
|
||||
@if (successMsg) {
|
||||
<div class="rrc-alert rrc-alert--success">{{ successMsg }}</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="rrc-loading">Loading…</div>
|
||||
} @else {
|
||||
<!-- Where the appropriated profit comes from. Not set on this screen: it is the "Distributable Profit"
|
||||
financial activity mapping, which the year-end close posts the year's net profit into. -->
|
||||
<div class="rrc-card rfc-source">
|
||||
<div class="rfc-source__icon"><i class="fas fa-coins"></i></div>
|
||||
<div class="rfc-source__body">
|
||||
<span class="rfc-source__label">Profit is taken from</span>
|
||||
@switch (profitSource.state) {
|
||||
@case ('mapped') {
|
||||
@if (profitSource.state === 'mapped') {
|
||||
<span class="rfc-source__gl">{{ profitSource.glCode }} · {{ profitSource.glName }}</span>
|
||||
}
|
||||
<span class="rrc-card__hint">
|
||||
The GL mapped to <strong>Distributable Profit</strong>. The year-end close posts the year's net profit
|
||||
into it, and each reserve below is credited from it.
|
||||
</span>
|
||||
}
|
||||
@case ('unmapped') {
|
||||
<span class="rfc-source__gl rfc-error">No profit GL is mapped</span>
|
||||
<span class="rrc-card__hint">
|
||||
Map a GL to <strong>Distributable Profit</strong> before any profit can be appropriated.
|
||||
</span>
|
||||
}
|
||||
@case ('unknown') {
|
||||
<span class="rfc-source__gl">The GL mapped to Distributable Profit</span>
|
||||
<span class="rrc-card__hint">You do not have access to view the financial activity mappings.</span>
|
||||
}
|
||||
@default {
|
||||
<span class="rrc-card__hint">Loading…</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (profitSource.state === 'mapped') {
|
||||
<a
|
||||
mat-stroked-button
|
||||
class="rfc-source__link"
|
||||
[routerLink]="['/accounting/financial-activity-mappings/view', profitSource.mappingId]"
|
||||
>
|
||||
Change
|
||||
</a>
|
||||
} @else if (profitSource.state === 'unmapped') {
|
||||
<a mat-stroked-button class="rfc-source__link" [routerLink]="['/accounting/financial-activity-mappings']">
|
||||
Map profit GL
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rrc-card">
|
||||
<h3 class="rrc-card__title">Reserve funds</h3>
|
||||
<p class="rrc-card__hint">
|
||||
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.
|
||||
</p>
|
||||
|
||||
@if (rows.length) {
|
||||
<div class="rfc-table-wrap">
|
||||
<table class="rrc-map rfc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rfc-sr">#</th>
|
||||
<th>Reserve / fund</th>
|
||||
<th>Credited to (GL)</th>
|
||||
<th class="rrc-map__narrow">
|
||||
% of profit
|
||||
@if (profitSource.state === 'mapped') {
|
||||
<span class="rfc-th-sub">from {{ profitSource.glCode }}</span>
|
||||
}
|
||||
</th>
|
||||
<th class="rrc-map__actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track row.rowId; let i = $index) {
|
||||
<tr [class.rrc-map__row--invalid]="isRowInvalid(row)">
|
||||
<td class="rfc-sr">{{ i + 1 }}</td>
|
||||
<td>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="row.name"
|
||||
maxlength="100"
|
||||
placeholder="e.g. Statutory Reserve"
|
||||
[disabled]="saving"
|
||||
aria-label="Reserve name"
|
||||
/>
|
||||
@if (isNameDuplicate(row)) {
|
||||
<mat-hint class="rfc-error">Listed twice</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</td>
|
||||
<td>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
|
||||
<mat-select
|
||||
[(ngModel)]="row.glAccountId"
|
||||
[disabled]="saving"
|
||||
placeholder="Select GL account"
|
||||
(openedChange)="accountSearch = ''"
|
||||
aria-label="GL account"
|
||||
>
|
||||
<mat-option>
|
||||
<ngx-mat-select-search
|
||||
[(ngModel)]="accountSearch"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
placeholderLabel="Search by GL code or name"
|
||||
noEntriesFoundLabel="No account matches"
|
||||
></ngx-mat-select-search>
|
||||
</mat-option>
|
||||
<mat-option [value]="null">— Not selected —</mat-option>
|
||||
@for (o of availableAccounts(row); track o.id) {
|
||||
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (row.glAccountId == null) {
|
||||
<mat-hint class="rfc-warn">GL not selected</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</td>
|
||||
<td>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
[(ngModel)]="row.percentage"
|
||||
[disabled]="saving"
|
||||
aria-label="Percentage of net profit"
|
||||
/>
|
||||
<span matTextSuffix>%</span>
|
||||
</mat-form-field>
|
||||
</td>
|
||||
<td class="rrc-map__actions">
|
||||
<button
|
||||
mat-button
|
||||
class="rrc-remove"
|
||||
(click)="removeRow(row)"
|
||||
[disabled]="saving"
|
||||
title="Remove this reserve"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="rfc-total" [class.rfc-total--over]="overLimit">
|
||||
<td></td>
|
||||
<td colspan="2">Total appropriated</td>
|
||||
<td class="rrc-num">{{ totalPercentage }}%</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr class="rfc-remaining">
|
||||
<td></td>
|
||||
<td colspan="2">Remaining in profit</td>
|
||||
<td class="rrc-num">{{ remainingPercentage }}%</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rrc-alert rrc-alert--warn">No reserve fund is configured yet. Add one to begin.</div>
|
||||
}
|
||||
|
||||
@if (overLimit) {
|
||||
<div class="rrc-alert rrc-alert--error">
|
||||
The reserves add up to {{ totalPercentage }}% of net profit. The total cannot exceed 100%.
|
||||
</div>
|
||||
}
|
||||
@if (unmappedCount > 0) {
|
||||
<div class="rrc-alert rrc-alert--warn">
|
||||
{{ unmappedCount }} reserve{{ unmappedCount === 1 ? ' has' : 's have' }} no GL account yet. Create the account
|
||||
in Chart of Accounts if needed, then select it here.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="rrc-role__controls">
|
||||
<button mat-stroked-button (click)="addRow()" [disabled]="saving">
|
||||
<i class="fas fa-plus"></i> Add reserve
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!canSave" (click)="save()">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
@if (dirty && !saving) {
|
||||
<button mat-button (click)="reset()">Discard changes</button>
|
||||
}
|
||||
@if (updatedOn) {
|
||||
<span class="rrc-card__hint">
|
||||
Last saved {{ updatedOn | slice: 0 : 16 }}
|
||||
@if (updatedBy) {
|
||||
by {{ updatedBy }}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Post to reserves ═══ -->
|
||||
<div class="rrc-card">
|
||||
<h3 class="rrc-card__title">Post to reserves</h3>
|
||||
<p class="rrc-card__hint">
|
||||
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.
|
||||
</p>
|
||||
|
||||
@if (postingError) {
|
||||
<div class="rrc-alert rrc-alert--error">{{ postingError }}</div>
|
||||
}
|
||||
@if (postingSuccess) {
|
||||
<div class="rrc-alert rrc-alert--success">{{ postingSuccess }}</div>
|
||||
}
|
||||
|
||||
<div class="rfc-post-head">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rfc-post-head__fy">
|
||||
<mat-label>Financial year</mat-label>
|
||||
<mat-select
|
||||
[(ngModel)]="postingYear"
|
||||
(selectionChange)="loadPosting($event.value)"
|
||||
[disabled]="postingLoading || postingBusy"
|
||||
>
|
||||
@for (fy of posting?.financialYearOptions || []; track fy) {
|
||||
<mat-option [value]="fy">FY {{ fy }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@if (posting) {
|
||||
<div class="rfc-post-head__profit">
|
||||
<span class="rfc-source__label">Profit in {{ posting.profitGlCode || 'profit GL' }}</span>
|
||||
<span class="rfc-source__gl">₹{{ posting.profitAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="rfc-post-head__status">
|
||||
@if (posting.posted) {
|
||||
<span class="rrc-badge rrc-badge--mapped">Posted</span>
|
||||
} @else if (posting.yearClosed) {
|
||||
<span class="rrc-badge rrc-badge--active">Year closed</span>
|
||||
} @else {
|
||||
<span class="rrc-badge rrc-badge--unmapped">Year not closed</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (postingLoading) {
|
||||
<div class="rrc-loading">Loading…</div>
|
||||
} @else if (posting) {
|
||||
<div class="rfc-table-wrap">
|
||||
<table class="rrc-map rfc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reserve / fund</th>
|
||||
<th>Credited to (GL)</th>
|
||||
<th class="rrc-map__narrow">%</th>
|
||||
<th class="rfc-post-amt">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of posting.lines || []; track $index) {
|
||||
<tr>
|
||||
<td>{{ l.reserveName }}</td>
|
||||
<td>
|
||||
@if (l.glCode) {
|
||||
{{ l.glCode }} · {{ l.glAccountName }}
|
||||
} @else {
|
||||
<span class="rfc-warn">GL not selected</span>
|
||||
}
|
||||
</td>
|
||||
<td class="rrc-num">{{ l.percentage }}%</td>
|
||||
<td class="rfc-post-amt rrc-num">₹{{ l.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="rfc-total">
|
||||
<td colspan="3">{{ posting.posted ? 'Posted to reserves' : 'To be posted to reserves' }}</td>
|
||||
<td class="rfc-post-amt rrc-num">₹{{ posting.totalAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
<tr class="rfc-remaining">
|
||||
<td colspan="3">Remaining in {{ posting.profitGlCode || 'profit GL' }}</td>
|
||||
<td class="rfc-post-amt rrc-num">₹{{ posting.remainingAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (posting.posted) {
|
||||
<p class="rrc-card__hint">
|
||||
Posted on {{ posting.postedOnDate }} by {{ posting.postedBy || '—' }} · Transaction
|
||||
<strong>{{ posting.glTransactionId }}</strong>
|
||||
@if (posting.note) {
|
||||
· {{ posting.note }}
|
||||
}
|
||||
</p>
|
||||
} @else {
|
||||
@for (b of posting.blockers || []; track $index) {
|
||||
<div class="rrc-alert rrc-alert--warn">{{ b }}</div>
|
||||
}
|
||||
@if (dirty) {
|
||||
<div class="rrc-alert rrc-alert--warn">
|
||||
Save the reserve changes above first — posting uses the saved configuration.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="rfc-post-actions">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rfc-post-actions__note">
|
||||
<mat-label>Note (e.g. AGM / board resolution no.)</mat-label>
|
||||
<input matInput [(ngModel)]="postingNote" maxlength="500" [disabled]="!canPost" />
|
||||
</mat-form-field>
|
||||
@if (!postingConfirm) {
|
||||
<button mat-raised-button color="primary" [disabled]="!canPost" (click)="postingConfirm = true">
|
||||
Post to reserves
|
||||
</button>
|
||||
} @else {
|
||||
<span class="rfc-post-actions__confirm">
|
||||
Post ₹{{ posting.totalAmount | number: '1.2-2' }} from {{ posting.profitGlCode }} for FY
|
||||
{{ postingYear }}? This cannot be undone here.
|
||||
</span>
|
||||
<button mat-raised-button color="warn" [disabled]="postingBusy" (click)="post()">
|
||||
{{ postingBusy ? 'Posting…' : 'Confirm post' }}
|
||||
</button>
|
||||
<button mat-button [disabled]="postingBusy" (click)="postingConfirm = false">Cancel</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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<ReserveRow, 'rowId'>) => ({
|
||||
...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;
|
||||
}
|
||||
}
|
||||
+53
-42
@@ -452,15 +452,22 @@
|
||||
|
||||
<mat-divider class="flex-100"></mat-divider>
|
||||
@if (isCashOrAccrualAccounting()) {
|
||||
<!-- Rows mirror the Accounting step: receivable/payable rows exist only under Accrual (a current account
|
||||
product is always Cash), Escheat only while dormancy tracking is on. -->
|
||||
<div class="spa-gl-summary flex-100">
|
||||
<div class="spa-gl-summary__header">
|
||||
<h4 class="mat-h4">GL Mapping</h4>
|
||||
</div>
|
||||
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Accounting Rule</span>
|
||||
<span class="spa-gl-row__value">{{ accountingRule?.value }}</span>
|
||||
</div>
|
||||
|
||||
<div class="spa-gl-section">
|
||||
<h5 class="mat-h5">Assets</h5>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Overdraft portfolio control</span>
|
||||
<span class="spa-gl-row__label">Overdraft Portfolio Control</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.overdraftPortfolioControlId,
|
||||
@@ -468,39 +475,41 @@
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Interest receivable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.interestReceivableAccountId,
|
||||
CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Fees receivable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.feesReceivableAccountId,
|
||||
CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Penalties receivable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.penaltiesReceivableAccountId,
|
||||
CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
@if (isAccrualAccounting()) {
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Interest Receivable Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.interestReceivableAccountId,
|
||||
CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Fees Receivable Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.feesReceivableAccountId,
|
||||
CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Penalties Receivable Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.penaltiesReceivableAccountId,
|
||||
CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="spa-gl-section">
|
||||
<h5 class="mat-h5">Liabilities</h5>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Current control account</span>
|
||||
<span class="spa-gl-row__label">Current Control Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.savingsControlAccountId,
|
||||
@@ -508,18 +517,20 @@
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Interest payable account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.interestPayableAccountId,
|
||||
CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
@if (isAccrualAccounting()) {
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Interest Payable Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.interestPayableAccountId,
|
||||
CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
@if (CurrentProduct.isDormancyTrackingActive) {
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Escheat liability account</span>
|
||||
<span class="spa-gl-row__label">Escheat Liability Account</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.escheatLiabilityId,
|
||||
@@ -533,7 +544,7 @@
|
||||
<div class="spa-gl-section">
|
||||
<h5 class="mat-h5">Income</h5>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Income from fees</span>
|
||||
<span class="spa-gl-row__label">Income From Fees</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.incomeFromFeeAccountId,
|
||||
@@ -542,7 +553,7 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Income from penalties</span>
|
||||
<span class="spa-gl-row__label">Income From Penalties</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.incomeFromPenaltyAccountId,
|
||||
@@ -551,7 +562,7 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="spa-gl-row">
|
||||
<span class="spa-gl-row__label">Income from interest</span>
|
||||
<span class="spa-gl-row__label">Income From Interest</span>
|
||||
<span class="spa-gl-row__value">{{
|
||||
formatGlAccountById(
|
||||
CurrentProduct.incomeFromInterestId,
|
||||
|
||||
+23
-2
@@ -79,12 +79,25 @@
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Applies To</mat-label>
|
||||
<mat-label>Account Type</mat-label>
|
||||
<mat-select formControlName="txnAppliesTo">
|
||||
<mat-option *ngFor="let a of appliesToOptions" [value]="a">{{ a }}</mat-option>
|
||||
<mat-option *ngFor="let a of accountTypes" [value]="a.value">{{ a.label }}</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Which accounts the rule watches</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Direction</mat-label>
|
||||
<mat-select formControlName="txnDirection">
|
||||
<mat-option *ngFor="let d of directionOptions" [value]="d.value">{{ d.label }}</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Credit = paisa aaya, Debit = paisa gaya</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="rule-cash-only">
|
||||
<mat-checkbox formControlName="txnCashOnly">Cash transactions only</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>AML Check Status</mat-label>
|
||||
<mat-select formControlName="amlCheckStatus">
|
||||
@@ -105,6 +118,14 @@
|
||||
<span>Maximum limit cannot be lower than the minimum — the rule would never match.</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isCcRule" class="rule-form-info">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="rule-form-note">
|
||||
Amount or window details also create the matching live-transaction rule. Leave them blank for a profile-only rule.
|
||||
</p>
|
||||
|
||||
+29
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+29
-5
@@ -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)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cc-intro">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
<div>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-controls">
|
||||
<label class="cc-field">
|
||||
<span>Window</span>
|
||||
<select [(ngModel)]="days" (ngModelChange)="load()">
|
||||
@for (w of windows; track w.days) {
|
||||
<option [ngValue]="w.days">{{ w.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span class="cc-asof">
|
||||
@if (asOf) {
|
||||
{{ from | date: 'dd MMM yyyy' }} to {{ asOf | date: 'dd MMM yyyy' }}
|
||||
}
|
||||
</span>
|
||||
<button type="button" class="cc-refresh" (click)="load()" matTooltip="Reload">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cc-cards">
|
||||
<div class="cc-card cc-card--in">
|
||||
<div class="cc-card__n">₹{{ totalIn | number: '1.2-2' }}</div>
|
||||
<div class="cc-card__l">Came in</div>
|
||||
</div>
|
||||
<div class="cc-card cc-card--out">
|
||||
<div class="cc-card__n">₹{{ totalOut | number: '1.2-2' }}</div>
|
||||
<div class="cc-card__l">Went out</div>
|
||||
</div>
|
||||
<div class="cc-card">
|
||||
<div class="cc-card__n">{{ shown.length }}</div>
|
||||
<div class="cc-card__l">CC accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="cc-note">Loading the CC account figures…</div>
|
||||
} @else if (error) {
|
||||
<div class="cc-note cc-note--warn">{{ error }}</div>
|
||||
} @else if (!shown.length) {
|
||||
<div class="cc-note">
|
||||
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).
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cc-table-wrap">
|
||||
<table class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Customer / Account</th>
|
||||
<th>Branch</th>
|
||||
<th class="r">Limit</th>
|
||||
<th class="r">Drawn</th>
|
||||
<th class="r">Declared / month</th>
|
||||
<th class="r">Came in</th>
|
||||
<th class="r">Went out</th>
|
||||
<th class="r">Net</th>
|
||||
<th class="r">In ÷ declared</th>
|
||||
<th class="r">Cash in</th>
|
||||
<th>Last txn</th>
|
||||
<th>CC rules</th>
|
||||
<th class="r">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of shown; track a.accountId; let i = $index) {
|
||||
<tr class="cc-row" (click)="openTrail(a, 'analysis')">
|
||||
<td>{{ i + 1 }}</td>
|
||||
<td>
|
||||
<div class="cc-name">
|
||||
{{ a.clientName }}
|
||||
<span class="cc-badge" [class.cc-badge--cc]="a.isCc">{{ a.isCc ? 'CC' : 'Current' }}</span>
|
||||
</div>
|
||||
<div class="cc-sub mono">{{ a.accountNo }}</div>
|
||||
</td>
|
||||
<td>{{ a.branchName || '—' }}</td>
|
||||
<td class="r">{{ a.isCc ? '₹' + (a.sanctionedLimit | number: '1.0-0') : '—' }}</td>
|
||||
<td class="r">{{ a.limitDrawnPercent != null ? a.limitDrawnPercent + '%' : '—' }}</td>
|
||||
<td class="r">
|
||||
{{
|
||||
a.declaredMonthlyTurnover != null ? '₹' + (a.declaredMonthlyTurnover | number: '1.0-0') : 'not declared'
|
||||
}}
|
||||
</td>
|
||||
<td class="r in">
|
||||
₹{{ a.credits | number: '1.2-2' }}
|
||||
<div class="cc-sub">{{ a.creditCount }} txn</div>
|
||||
</td>
|
||||
<td class="r out">
|
||||
₹{{ a.debits | number: '1.2-2' }}
|
||||
<div class="cc-sub">{{ a.debitCount }} txn</div>
|
||||
</td>
|
||||
<td class="r" [class.in]="a.net > 0" [class.out]="a.net < 0">{{ rs(a.net) }}</td>
|
||||
<td class="r">
|
||||
@if (a.creditsVsDeclared != null) {
|
||||
<span
|
||||
class="cc-ratio"
|
||||
[class.cc-ratio--hi]="isSpike(a.creditsVsDeclared)"
|
||||
[matTooltip]="isSpike(a.creditsVsDeclared) ? 'Well above the declared turnover for this window' : ''"
|
||||
>
|
||||
{{ a.creditsVsDeclared }}×
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td class="r">{{ a.cashCreditPercent != null ? a.cashCreditPercent + '%' : '—' }}</td>
|
||||
<td>{{ a.lastTxnDate ? (a.lastTxnDate | date: 'dd MMM yyyy') : '—' }}</td>
|
||||
<td
|
||||
class="cc-rules-cell"
|
||||
(click)="openTrail(a, 'rules'); $event.stopPropagation()"
|
||||
title="See which CC rules this account hits"
|
||||
>
|
||||
<span class="cc-hit" [class.cc-hit--on]="a.rulesHit > 0">{{ a.rulesHit }} hit</span>
|
||||
<div class="cc-sub">{{ a.rulesNoHit }} no hit · {{ a.rulesNotChecked }} not checked</div>
|
||||
</td>
|
||||
<td class="r cc-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cc-trail-btn cc-trail-btn--main"
|
||||
(click)="openTrail(a, 'analysis'); $event.stopPropagation()"
|
||||
>
|
||||
Analysis
|
||||
</button>
|
||||
<button type="button" class="cc-trail-btn" (click)="openTrail(a, 'trail'); $event.stopPropagation()">
|
||||
Trail
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="cc-foot">
|
||||
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.
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- Where one account's money came from and went, and what it adds up to. Opened from a row above; declared here so it
|
||||
keeps this component's state and styles. Credits are written Cr and debits Dr, as in the bank's books. -->
|
||||
<ng-template #trailDialog>
|
||||
<div class="cc-dialog">
|
||||
@if (trailLoading) {
|
||||
<div class="cc-note">Loading the account's money trail…</div>
|
||||
} @else if (trailError) {
|
||||
<div class="cc-note cc-note--warn">{{ trailError }}</div>
|
||||
<div class="cc-dialog__bar"><button type="button" class="cc-btn" (click)="closeTrail()">Close</button></div>
|
||||
} @else if (trail) {
|
||||
<div class="cc-dialog__head">
|
||||
<div>
|
||||
<div class="cc-dialog__title">
|
||||
{{ trail.clientName }}
|
||||
<span class="cc-badge" [class.cc-badge--cc]="trail.isCc">{{ trail.isCc ? 'CC' : 'Current' }}</span>
|
||||
</div>
|
||||
<div class="cc-sub mono">{{ trail.accountNo }} · {{ trail.branchName }}</div>
|
||||
<div class="cc-sub">
|
||||
{{ trail.from | date: 'dd MMM yyyy' }} to {{ trail.asOf | date: 'dd MMM yyyy' }} · Sanctioned limit ₹{{
|
||||
trail.sanctionedLimit | number: '1.0-0'
|
||||
}}
|
||||
· Closing balance <strong>{{ drcr(trail.balance) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="cc-btn" (click)="closeTrail()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="cc-legend">
|
||||
<span><strong class="in">Cr (credit)</strong> = money received into the account.</span>
|
||||
<span><strong class="out">Dr (debit)</strong> = money paid out of the account.</span>
|
||||
<span
|
||||
>On a CC account, a <strong class="out">Dr balance</strong> means the customer has drawn on the overdraft,
|
||||
that is, owes the bank.</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="cc-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="cc-tab"
|
||||
[class.cc-tab--on]="trailView === 'analysis'"
|
||||
(click)="trailView = 'analysis'"
|
||||
>
|
||||
Analysis
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cc-tab"
|
||||
[class.cc-tab--on]="trailView === 'rules'"
|
||||
(click)="trailView = 'rules'; ruleFilter = 'HIT'"
|
||||
>
|
||||
CC rules ({{ ruleCount('HIT') }} hit)
|
||||
</button>
|
||||
<button type="button" class="cc-tab" [class.cc-tab--on]="trailView === 'trail'" (click)="trailView = 'trail'">
|
||||
Every transaction ({{ trail.transactions.length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (trailView === 'rules') {
|
||||
<!-- Which CC rules this account hits, and which it does not -->
|
||||
<div class="cc-rulesbar">
|
||||
<button type="button" class="cc-chip" [class.cc-chip--on]="ruleFilter === 'HIT'" (click)="ruleFilter = 'HIT'">
|
||||
Hit <span class="cc-chip__n">{{ ruleCount('HIT') }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cc-chip"
|
||||
[class.cc-chip--on]="ruleFilter === 'NO_HIT'"
|
||||
(click)="ruleFilter = 'NO_HIT'"
|
||||
>
|
||||
No hit <span class="cc-chip__n">{{ ruleCount('NO_HIT') }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cc-chip"
|
||||
[class.cc-chip--on]="ruleFilter === 'NOT_CHECKED'"
|
||||
(click)="ruleFilter = 'NOT_CHECKED'"
|
||||
>
|
||||
Not checked <span class="cc-chip__n">{{ ruleCount('NOT_CHECKED') }}</span>
|
||||
</button>
|
||||
<button type="button" class="cc-chip" [class.cc-chip--on]="ruleFilter === 'ALL'" (click)="ruleFilter = 'ALL'">
|
||||
All <span class="cc-chip__n">{{ ruleCount('') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="cc-hint cc-hint--top">
|
||||
<strong>Hit</strong> = this account's transactions trigger the rule. <strong>No hit</strong> = the rule was
|
||||
checked and not triggered. <strong>Not checked</strong> = the rule needs an officer's judgement or data the
|
||||
system does not hold yet, so it is not shown as passed.
|
||||
</p>
|
||||
<div class="cc-table-wrap">
|
||||
<table class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rule</th>
|
||||
<th>Kind</th>
|
||||
<th>Risk</th>
|
||||
<th>Result</th>
|
||||
<th>Why</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filteredRuleChecks; track r.ruleId) {
|
||||
<tr>
|
||||
<td>
|
||||
{{ r.name }}
|
||||
@if (r.onTrial) {
|
||||
<span class="cc-trial">TRIAL</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.kind }}</td>
|
||||
<td>{{ riskLabel(r.riskCategory) }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="cc-res"
|
||||
[class.cc-res--hit]="r.status === 'HIT'"
|
||||
[class.cc-res--ok]="r.status === 'NO_HIT'"
|
||||
[class.cc-res--na]="r.status === 'NOT_CHECKED'"
|
||||
>
|
||||
{{ r.status === 'HIT' ? 'HIT' : r.status === 'NO_HIT' ? 'No hit' : 'Not checked' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="cc-sub-cell">{{ r.detail }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="cc-empty">No rule in this group.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else if (trailView === 'analysis') {
|
||||
<!-- In words -->
|
||||
<div class="cc-story">
|
||||
<div class="cc-story__title">In short</div>
|
||||
@for (line of story; track $index) {
|
||||
<p>{{ line }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- The figures -->
|
||||
<div class="cc-kpis">
|
||||
<div class="cc-kpi cc-kpi--in">
|
||||
<div class="cc-kpi__t">Total credits (Cr)</div>
|
||||
<div class="cc-kpi__n">₹{{ trail.summary.in | number: '1.2-2' }}</div>
|
||||
<div class="cc-kpi__l">
|
||||
{{ trail.summary.inCount }} credit{{ trail.summary.inCount === 1 ? '' : 's' }} · money received
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-kpi cc-kpi--out">
|
||||
<div class="cc-kpi__t">Total debits (Dr)</div>
|
||||
<div class="cc-kpi__n">₹{{ trail.summary.out | number: '1.2-2' }}</div>
|
||||
<div class="cc-kpi__l">
|
||||
{{ trail.summary.outCount }} debit{{ trail.summary.outCount === 1 ? '' : 's' }} · money paid out
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-kpi">
|
||||
<div class="cc-kpi__t">Net (Cr − Dr)</div>
|
||||
<div class="cc-kpi__n" [class.in]="trail.summary.net > 0" [class.out]="trail.summary.net < 0">
|
||||
{{ drcr(trail.summary.net) }}
|
||||
</div>
|
||||
<div class="cc-kpi__l">
|
||||
{{
|
||||
trail.summary.net < 0
|
||||
? 'Debits were more than credits'
|
||||
: trail.summary.net > 0
|
||||
? 'Credits were more than debits'
|
||||
: 'Credits equal debits'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-kpi">
|
||||
<div class="cc-kpi__t">Closing balance</div>
|
||||
<div class="cc-kpi__n" [class.in]="trail.balance > 0" [class.out]="trail.balance < 0">
|
||||
{{ drcr(trail.balance) }}
|
||||
</div>
|
||||
<div class="cc-kpi__l">
|
||||
{{
|
||||
trail.balance < 0
|
||||
? 'Overdrawn: the customer owes the bank'
|
||||
: trail.balance > 0
|
||||
? "The customer's own money"
|
||||
: 'Nil'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-kpi">
|
||||
<div class="cc-kpi__t">Lowest balance</div>
|
||||
<div class="cc-kpi__n">{{ drcr(trail.summary.lowestBalance, '1.0-0') }}</div>
|
||||
<div class="cc-kpi__l">
|
||||
{{
|
||||
trail.summary.peakLimitUsedPercent != null
|
||||
? trail.summary.peakLimitUsedPercent + '% of the limit used at the worst point'
|
||||
: 'Never overdrawn'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-facts">
|
||||
<span
|
||||
>Active days: <strong>{{ trail.summary.activeDays }}</strong></span
|
||||
>
|
||||
<span
|
||||
>Average credit:
|
||||
<strong>{{
|
||||
trail.summary.averageIn != null ? '₹' + (trail.summary.averageIn | number: '1.0-0') : '—'
|
||||
}}</strong></span
|
||||
>
|
||||
<span
|
||||
>Average debit:
|
||||
<strong>{{
|
||||
trail.summary.averageOut != null ? '₹' + (trail.summary.averageOut | number: '1.0-0') : '—'
|
||||
}}</strong></span
|
||||
>
|
||||
<span>
|
||||
Declared turnover for the window:
|
||||
<strong>{{
|
||||
trail.summary.declaredForWindow != null
|
||||
? '₹' + (trail.summary.declaredForWindow | number: '1.0-0')
|
||||
: 'not declared'
|
||||
}}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Credits ÷ declared:
|
||||
<strong>{{ trail.summary.creditsVsDeclared != null ? trail.summary.creditsVsDeclared + '×' : '—' }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- What stands out -->
|
||||
<div class="cc-group__title cc-group__title--pad">What stands out</div>
|
||||
<div class="cc-obs">
|
||||
@for (o of trail.observations; track $index) {
|
||||
<div class="cc-ob" [ngClass]="'cc-ob--' + o.level.toLowerCase()">
|
||||
<span class="cc-ob__lvl">{{ o.level }}</span>
|
||||
<div>
|
||||
<div class="cc-ob__t">{{ o.title }}</div>
|
||||
<div class="cc-ob__d">{{ o.detail }}</div>
|
||||
@if (o.why) {
|
||||
<div class="cc-ob__w"><strong>Why it matters:</strong> {{ o.why }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cc-groups">
|
||||
<!-- By mode -->
|
||||
<div class="cc-group">
|
||||
<div class="cc-group__title">By mode of transaction</div>
|
||||
<table class="cc-mini">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mode</th>
|
||||
<th class="r">Credit (Cr)</th>
|
||||
<th class="r">Debit (Dr)</th>
|
||||
<th class="r">Net</th>
|
||||
<th class="r">Txns</th>
|
||||
<th class="r">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of trail.byMode; track m.label) {
|
||||
<tr>
|
||||
<td>{{ m.label }}</td>
|
||||
<td class="r in">₹{{ m.in | number: '1.0-0' }}</td>
|
||||
<td class="r out">₹{{ m.out | number: '1.0-0' }}</td>
|
||||
<td class="r">{{ drcr(m.in - m.out, '1.0-0') }}</td>
|
||||
<td class="r">{{ m.count }}</td>
|
||||
<td class="r">
|
||||
<span class="cc-bar"><span class="cc-bar__fill" [style.width.%]="m.sharePercent"></span></span>
|
||||
{{ m.sharePercent }}%
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="cc-empty">No transactions in this window.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="cc-hint">Share = this mode's part of all the money that moved (credits + debits).</p>
|
||||
</div>
|
||||
|
||||
<!-- By the party on the other side -->
|
||||
<div class="cc-group">
|
||||
<div class="cc-group__title">Who the money came from (Cr) and went to (Dr)</div>
|
||||
<table class="cc-mini">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account / Bank</th>
|
||||
<th class="r">Credit (Cr)</th>
|
||||
<th class="r">Debit (Dr)</th>
|
||||
<th class="r">Net</th>
|
||||
<th class="r">Txns</th>
|
||||
<th class="r">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of trail.byCounterparty; track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="mono">{{ p.label }}</span>
|
||||
<div class="cc-sub">{{ p.sub || '' }}</div>
|
||||
</td>
|
||||
<td class="r in">₹{{ p.in | number: '1.0-0' }}</td>
|
||||
<td class="r out">₹{{ p.out | number: '1.0-0' }}</td>
|
||||
<td class="r">{{ drcr(p.in - p.out, '1.0-0') }}</td>
|
||||
<td class="r">{{ p.count }}</td>
|
||||
<td class="r">
|
||||
<span class="cc-bar"><span class="cc-bar__fill" [style.width.%]="p.sharePercent"></span></span>
|
||||
{{ p.sharePercent }}%
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="cc-empty">—</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="cc-hint">Cr from a party = it paid this account. Dr to a party = this account paid it.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Week by week -->
|
||||
<div class="cc-group__title cc-group__title--pad">Week by week</div>
|
||||
<table class="cc-mini cc-weeks">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Week starting</th>
|
||||
<th class="r">Credit (Cr)</th>
|
||||
<th class="r">Debit (Dr)</th>
|
||||
<th class="r">Net for the week</th>
|
||||
<th class="r">Txns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (w of trail.timeline; track w.label) {
|
||||
<tr>
|
||||
<td>{{ w.label | date: 'dd MMM yyyy' }}</td>
|
||||
<td class="r in">₹{{ w.in | number: '1.0-0' }}</td>
|
||||
<td class="r out">₹{{ w.out | number: '1.0-0' }}</td>
|
||||
<td class="r">{{ drcr(w.in - w.out, '1.0-0') }}</td>
|
||||
<td class="r">{{ w.count }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- The biggest two -->
|
||||
<div class="cc-big">
|
||||
@if (trail.summary.largestIn) {
|
||||
<div class="cc-big__item">
|
||||
Largest credit (Cr): <strong class="in">₹{{ trail.summary.largestIn.amount | number: '1.2-2' }}</strong
|
||||
>, received from {{ trail.summary.largestIn.party }} by {{ trail.summary.largestIn.mode }} on
|
||||
{{ trail.summary.largestIn.date | date: 'dd MMM yyyy' }}.
|
||||
</div>
|
||||
}
|
||||
@if (trail.summary.largestOut) {
|
||||
<div class="cc-big__item">
|
||||
Largest debit (Dr): <strong class="out">₹{{ trail.summary.largestOut.amount | number: '1.2-2' }}</strong
|
||||
>, paid to {{ trail.summary.largestOut.party }} by {{ trail.summary.largestOut.mode }} on
|
||||
{{ trail.summary.largestOut.date | date: 'dd MMM yyyy' }}.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="cc-foot">
|
||||
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.
|
||||
</p>
|
||||
} @else {
|
||||
<div class="cc-table-wrap">
|
||||
<table class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Cr / Dr</th>
|
||||
<th>Mode</th>
|
||||
<th>Other side</th>
|
||||
<th>Reference</th>
|
||||
<th class="r">Credit (Cr)</th>
|
||||
<th class="r">Debit (Dr)</th>
|
||||
<th class="r">Balance after</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of trail.transactions; track t.id) {
|
||||
<tr>
|
||||
<td>{{ t.date | date: 'dd MMM yyyy' }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="cc-dir"
|
||||
[class.cc-dir--in]="t.direction === 'IN'"
|
||||
[class.cc-dir--out]="t.direction === 'OUT'"
|
||||
>
|
||||
{{ t.direction === 'IN' ? 'Cr · received' : 'Dr · paid out' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ t.mode }}
|
||||
@if (t.chequeNo) {
|
||||
<div class="cc-sub">No. {{ t.chequeNo }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">{{ t.counterpartyAccount || '—' }}</span>
|
||||
<div class="cc-sub">
|
||||
{{ t.counterpartyBank || '' }}{{ t.counterpartyIfsc ? ' · ' + t.counterpartyIfsc : '' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">{{ t.reference || '—' }}</span>
|
||||
<div class="cc-sub">{{ t.remarks }}</div>
|
||||
</td>
|
||||
<td class="r in">{{ t.direction === 'IN' ? '₹' + (t.amount | number: '1.2-2') : '' }}</td>
|
||||
<td class="r out">{{ t.direction === 'OUT' ? '₹' + (t.amount | number: '1.2-2') : '' }}</td>
|
||||
<td class="r">{{ drcr(t.balanceAfter) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="cc-empty">No transactions in this window.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="cc-foot">
|
||||
“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.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
private trailRef: MatDialogRef<unknown> | 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;
|
||||
}
|
||||
}
|
||||
+139
-148
@@ -303,153 +303,6 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- The expanded pane. One cell spanning the table, collapsed to zero height until its row is
|
||||
opened, so the detail sits directly under the row it belongs to. -->
|
||||
<ng-container matColumnDef="expandedDetail">
|
||||
<!-- No header cell: this column never appears in the header row, only in its own data row. -->
|
||||
<td mat-cell *matCellDef="let alert" [attr.colspan]="displayedColumns.length" class="detail-cell">
|
||||
<!-- The pane is inside a row that toggles on click, so anything interactive in here must keep its own
|
||||
clicks from reaching it — otherwise ticking a rule closes the pane. -->
|
||||
<div class="detail-pane" [class.detail-open]="isExpanded(alert)" (click)="$event.stopPropagation()">
|
||||
@if (isExpanded(alert)) {
|
||||
<div class="detail-inner">
|
||||
<div class="detail-head">
|
||||
<span
|
||||
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
|
||||
{{ amount(alert) }}</span
|
||||
>
|
||||
@if (selectableRulesOf(alert).length > 1) {
|
||||
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
|
||||
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Governing rule first: it is the one the alert was raised under, and the one the
|
||||
disposition is recorded against. -->
|
||||
@for (rule of matchedRules(alert); track rule.ruleCode) {
|
||||
<div
|
||||
class="detail-rule"
|
||||
[class.detail-rule-governing]="rule.governing"
|
||||
[class.detail-rule-decided]="rule.disposition"
|
||||
>
|
||||
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
|
||||
customer's business and the sixth is not, and those are different decisions. A rule already
|
||||
decided shows its verdict instead. -->
|
||||
@if (isRuleSelectable(rule)) {
|
||||
<mat-checkbox
|
||||
[checked]="isRuleSelected(rule)"
|
||||
(change)="toggleRuleSelected(rule)"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
></mat-checkbox>
|
||||
} @else if (!isGradedRule(rule)) {
|
||||
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
|
||||
about the transaction, so there is nothing here to tick. -->
|
||||
<mat-icon
|
||||
class="rule-decided-icon decided-statutory"
|
||||
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
gavel
|
||||
</mat-icon>
|
||||
} @else {
|
||||
<mat-icon
|
||||
class="rule-decided-icon"
|
||||
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
|
||||
[class.decided-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
|
||||
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
|
||||
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
|
||||
"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
{{
|
||||
!rule.directlyDecided
|
||||
? 'remove_circle_outline'
|
||||
: rule.disposition === 'ESCALATE'
|
||||
? 'report'
|
||||
: 'verified'
|
||||
}}
|
||||
</mat-icon>
|
||||
}
|
||||
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
|
||||
severityLabel(rule.riskCategory)
|
||||
}}</span>
|
||||
<div class="detail-rule-text">
|
||||
<span class="detail-rule-code">
|
||||
{{ rule.ruleCode }}
|
||||
@if (rule.governing) {
|
||||
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
|
||||
>governing</span
|
||||
>
|
||||
}
|
||||
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
|
||||
transaction being unreported. -->
|
||||
@if (!isGradedRule(rule)) {
|
||||
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
|
||||
→ CTR filing
|
||||
</span>
|
||||
}
|
||||
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
|
||||
because an audit of the finding turns on which one they put their name to. -->
|
||||
@if (rule.disposition) {
|
||||
<span
|
||||
class="rule-verdict"
|
||||
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
|
||||
[class.verdict-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
rule.directlyDecided
|
||||
? ruleDispositionLabel(rule) +
|
||||
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
|
||||
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
|
||||
"
|
||||
>
|
||||
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
|
||||
clearing five of six rules leaves the case open on the sixth. -->
|
||||
@if (selectedRuleCount > 0) {
|
||||
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<span class="rule-action-count"
|
||||
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
|
||||
>
|
||||
<span class="bulk-spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="reviewSelectedRules(alert, 'WHITELIST')"
|
||||
>
|
||||
Whitelist
|
||||
</button>
|
||||
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
|
||||
Suspicious
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="detail-foot">
|
||||
@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.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Amount</th>
|
||||
<td mat-cell *matCellDef="let alert" class="col-right amount-cell">{{ amount(alert) }}</td>
|
||||
@@ -541,9 +394,147 @@
|
||||
[class.row-expanded]="isExpanded(row)"
|
||||
(click)="toggleExpanded(row)"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
|
||||
</table>
|
||||
|
||||
<!-- Every rule a transaction tripped, in a popup rather than a pane pushed in under the row. Declared here so it keeps
|
||||
this component's state and styles; opened by toggleExpanded(). -->
|
||||
<ng-template #rulesDialog>
|
||||
<div class="rules-dialog">
|
||||
<button type="button" class="rules-dialog-close" mat-icon-button mat-dialog-close aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
@if (expandedAlert; as alert) {
|
||||
<div class="detail-inner">
|
||||
<div class="detail-head">
|
||||
<span
|
||||
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
|
||||
{{ amount(alert) }}</span
|
||||
>
|
||||
@if (selectableRulesOf(alert).length > 1) {
|
||||
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
|
||||
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Governing rule first: it is the one the alert was raised under, and the one the
|
||||
disposition is recorded against. -->
|
||||
@for (rule of matchedRules(alert); track rule.ruleCode) {
|
||||
<div
|
||||
class="detail-rule"
|
||||
[class.detail-rule-governing]="rule.governing"
|
||||
[class.detail-rule-decided]="rule.disposition"
|
||||
>
|
||||
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
|
||||
customer's business and the sixth is not, and those are different decisions. A rule already
|
||||
decided shows its verdict instead. -->
|
||||
@if (isRuleSelectable(rule)) {
|
||||
<mat-checkbox
|
||||
[checked]="isRuleSelected(rule)"
|
||||
(change)="toggleRuleSelected(rule)"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
></mat-checkbox>
|
||||
} @else if (!isGradedRule(rule)) {
|
||||
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
|
||||
about the transaction, so there is nothing here to tick. -->
|
||||
<mat-icon
|
||||
class="rule-decided-icon decided-statutory"
|
||||
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
gavel
|
||||
</mat-icon>
|
||||
} @else {
|
||||
<mat-icon
|
||||
class="rule-decided-icon"
|
||||
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
|
||||
[class.decided-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
|
||||
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
|
||||
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
|
||||
"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
{{
|
||||
!rule.directlyDecided
|
||||
? 'remove_circle_outline'
|
||||
: rule.disposition === 'ESCALATE'
|
||||
? 'report'
|
||||
: 'verified'
|
||||
}}
|
||||
</mat-icon>
|
||||
}
|
||||
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
|
||||
severityLabel(rule.riskCategory)
|
||||
}}</span>
|
||||
<div class="detail-rule-text">
|
||||
<span class="detail-rule-code">
|
||||
{{ rule.ruleCode }}
|
||||
@if (rule.governing) {
|
||||
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
|
||||
>governing</span
|
||||
>
|
||||
}
|
||||
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
|
||||
transaction being unreported. -->
|
||||
@if (!isGradedRule(rule)) {
|
||||
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
|
||||
→ CTR filing
|
||||
</span>
|
||||
}
|
||||
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
|
||||
because an audit of the finding turns on which one they put their name to. -->
|
||||
@if (rule.disposition) {
|
||||
<span
|
||||
class="rule-verdict"
|
||||
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
|
||||
[class.verdict-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
rule.directlyDecided
|
||||
? ruleDispositionLabel(rule) + (rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
|
||||
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
|
||||
"
|
||||
>
|
||||
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
|
||||
clearing five of six rules leaves the case open on the sixth. -->
|
||||
@if (selectedRuleCount > 0) {
|
||||
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<span class="rule-action-count"
|
||||
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
|
||||
>
|
||||
<span class="bulk-spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
|
||||
<button mat-stroked-button color="primary" type="button" (click)="reviewSelectedRules(alert, 'WHITELIST')">
|
||||
Whitelist
|
||||
</button>
|
||||
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
|
||||
Suspicious
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="detail-foot">
|
||||
@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.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- pageSize stated explicitly: without it the paginator starts at 0, and anything that touches the
|
||||
paginator before the data source has bound to it leaves that 0 in force, which pages every row
|
||||
out of the table. -->
|
||||
|
||||
+27
-1
@@ -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;
|
||||
}
|
||||
|
||||
+18
-3
@@ -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<unknown>;
|
||||
|
||||
/** 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 {
|
||||
|
||||
+6
@@ -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;
|
||||
|
||||
@@ -58,6 +58,38 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- A newly added rule is on trial, listed first, until an officer marks it normal -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Status</span>
|
||||
<button
|
||||
*ngFor="let s of statusFilters"
|
||||
type="button"
|
||||
class="period-chip"
|
||||
[class.active]="activeStatus === s.key"
|
||||
[disabled]="statusCount(s.key) === 0 && activeStatus !== s.key"
|
||||
(click)="setStatus(s.key)"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span class="period-chip-n">{{ statusCount(s.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Separate the rules by the kind of account they watch -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Account</span>
|
||||
<button
|
||||
*ngFor="let a of accountFilters"
|
||||
type="button"
|
||||
class="period-chip"
|
||||
[class.active]="activeAccount === a.key"
|
||||
[disabled]="accountCount(a.key) === 0 && activeAccount !== a.key"
|
||||
(click)="setAccount(a.key)"
|
||||
>
|
||||
{{ a.label }}
|
||||
<span class="period-chip-n">{{ accountCount(a.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Separate the rules by the window they are measured over -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Applies over</span>
|
||||
@@ -84,7 +116,15 @@
|
||||
<!-- Rule Name Column -->
|
||||
<ng-container matColumnDef="ruleName">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
|
||||
<td mat-cell *matCellDef="let element" class="rule-name-cell">{{ element.ruleName }}</td>
|
||||
<td mat-cell *matCellDef="let element" class="rule-name-cell">
|
||||
{{ element.ruleName }}
|
||||
<span
|
||||
*ngIf="element.onTrial"
|
||||
class="trial-badge"
|
||||
[title]="'On trial since ' + (element.trialStartedOn | date: 'dd MMM yyyy')"
|
||||
>TRIAL · day {{ trialDays(element) + 1 }}</span
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Risk Category Column -->
|
||||
@@ -163,8 +203,24 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Trial: a newly added rule is on trial until an officer marks it normal -->
|
||||
<ng-container matColumnDef="trial">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Trial</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<button *ngIf="element.onTrial" mat-stroked-button type="button" (click)="markNormal(element)">
|
||||
Mark as normal
|
||||
</button>
|
||||
<span *ngIf="!element.onTrial" class="trial-normal">Normal</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="rule-row"
|
||||
[class.rule-row--trial]="row.onTrial"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<!-- Mat Paginator for standard pagination -->
|
||||
@@ -203,6 +259,15 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- How much each CC account has taken in and paid out. Lazily instantiated so it only loads when opened. -->
|
||||
<mat-tab label="CC Accounts">
|
||||
<ng-template matTabContent>
|
||||
<div class="aml-tab-body">
|
||||
<mifosx-aml-cc-tracking></mifosx-aml-cc-tracking>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
private accountCounts: Record<string, number> = {};
|
||||
|
||||
/** 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) {
|
||||
|
||||
@@ -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<string, Form9Line>();
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -62,200 +62,276 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="f9-sheet" id="f9-printable">
|
||||
<div class="f9-sheet__head">
|
||||
<div class="f9-sheet__title">{{ data.formName }}</div>
|
||||
<div class="f9-sheet__ref">{{ data.statutoryReference }} · Return code {{ data.returnCode }}</div>
|
||||
<div class="f9-sheet__unit">As at {{ data.reportingDate }} · {{ data.unit }}</div>
|
||||
</div>
|
||||
<div class="f9-viewtabs f9-noprint">
|
||||
<button mat-stroked-button [class.f9-viewtabs__on]="view === 'bank'" (click)="view = 'bank'">Bank format</button>
|
||||
<button mat-stroked-button [class.f9-viewtabs__on]="view === 'working'" (click)="view = 'working'">
|
||||
Working view
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (view === 'bank' && bank) {
|
||||
<!--
|
||||
═══ The bank's printed Form IX ═══
|
||||
Same heads, order and wording as the form the bank files, figures bare and in thousands.
|
||||
The working view above shows the accounts behind every figure; this one is the form.
|
||||
-->
|
||||
|
||||
<div class="bf" id="f9-printable">
|
||||
<div class="bf__top">
|
||||
<span>{{ details.address }}</span>
|
||||
<span class="bf__date">Date : {{ bankDate(data.reportingDate) }}</span>
|
||||
</div>
|
||||
<div class="bf__title">{{ details.formCode }} THE BANKING REGULATION FORM IX</div>
|
||||
<div class="bf__title">NAME & DESIG. OF THE OFFICER SUBMITTING THE RETURN</div>
|
||||
<div class="bf__officer">
|
||||
@if (details.officer || details.designation) {
|
||||
<span class="bf__title">
|
||||
{{ details.officer }}
|
||||
@if (details.officer && details.designation) {
|
||||
,
|
||||
}
|
||||
{{ details.designation }}
|
||||
</span>
|
||||
}
|
||||
<span class="bf__title">RS. IN THOUSAND</span>
|
||||
</div>
|
||||
|
||||
<table class="bf__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="bf__num">{{ bankDate(data.reportingDate) }}</th>
|
||||
<th class="bf__num">{{ bankDate(data.reportingDate) }}</th>
|
||||
<th class="bf__num">{{ bankDate(data.reportingDate) }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of bank.rows; track $index) {
|
||||
<tr [class]="'bf-row bf-row--' + row.kind" [class.bf-row--spaced]="row.spaced">
|
||||
<td class="bf__label" [style.padding-left.px]="row.indent * 18">{{ row.label }}</td>
|
||||
<td class="bf__num">{{ bankNum(row.value) }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
@if (row.echo !== undefined && row.echo !== null) {
|
||||
<tr class="bf-row bf-row--total">
|
||||
<td></td>
|
||||
<td class="bf__num">{{ bankNum(row.echo) }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="bf__sign">
|
||||
@if (details.bankName) {
|
||||
<div>FOR {{ details.bankName }}</div>
|
||||
}
|
||||
<div class="bf__sign-gap">AUTHORISED SIGNATORY</div>
|
||||
</div>
|
||||
<div class="bf__foot">
|
||||
<span>User : User Code:{{ userCode }}</span>
|
||||
<span>Print Date : {{ printedAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="f9-sheet" id="f9-printable">
|
||||
<div class="f9-sheet__head">
|
||||
<div class="f9-sheet__title">{{ data.formName }}</div>
|
||||
<div class="f9-sheet__ref">{{ data.statutoryReference }} · Return code {{ data.returnCode }}</div>
|
||||
<div class="f9-sheet__unit">As at {{ data.reportingDate }} · {{ data.unit }}</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
═══ Reconciliation ═══
|
||||
At the top, not the bottom. A return whose two sides disagree has something unmapped or
|
||||
mapped twice, and a reviewer who learns that after reading the statement has already
|
||||
believed the statement.
|
||||
-->
|
||||
<div class="f9-recon" [class.f9-recon--off]="!data.reconciliation.balanced">
|
||||
<div class="f9-recon__cell">
|
||||
<span class="f9-recon__label">Total Liabilities</span>
|
||||
<span class="f9-recon__value">{{ amount(data.reconciliation.totalLiabilities) }}</span>
|
||||
<div class="f9-recon" [class.f9-recon--off]="!data.reconciliation.balanced">
|
||||
<div class="f9-recon__cell">
|
||||
<span class="f9-recon__label">Total Liabilities</span>
|
||||
<span class="f9-recon__value">{{ amount(data.reconciliation.totalLiabilities) }}</span>
|
||||
</div>
|
||||
<div class="f9-recon__cell">
|
||||
<span class="f9-recon__label">Total Assets</span>
|
||||
<span class="f9-recon__value">{{ amount(data.reconciliation.totalAssets) }}</span>
|
||||
</div>
|
||||
<div class="f9-recon__cell">
|
||||
<span class="f9-recon__label">Difference</span>
|
||||
<span class="f9-recon__value" [class.f9-neg]="!data.reconciliation.balanced">
|
||||
{{ amount(data.reconciliation.difference) }}
|
||||
</span>
|
||||
<span class="f9-recon__sub">
|
||||
{{
|
||||
data.reconciliation.balanced
|
||||
? 'The two sides agree'
|
||||
: rupees(data.reconciliation.differenceInRupees) + ' in rupees'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f9-recon__cell">
|
||||
<span class="f9-recon__label">Total Assets</span>
|
||||
<span class="f9-recon__value">{{ amount(data.reconciliation.totalAssets) }}</span>
|
||||
</div>
|
||||
<div class="f9-recon__cell">
|
||||
<span class="f9-recon__label">Difference</span>
|
||||
<span class="f9-recon__value" [class.f9-neg]="!data.reconciliation.balanced">
|
||||
{{ amount(data.reconciliation.difference) }}
|
||||
</span>
|
||||
<span class="f9-recon__sub">
|
||||
{{
|
||||
data.reconciliation.balanced
|
||||
? 'The two sides agree'
|
||||
: rupees(data.reconciliation.differenceInRupees) + ' in rupees'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (data.caveats.length) {
|
||||
<div class="f9-alert f9-alert--warn">
|
||||
<strong>Check before filing.</strong>
|
||||
<ul class="f9-caveats">
|
||||
@for (caveat of data.caveats; track caveat) {
|
||||
<li>{{ caveat }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (data.caveats.length) {
|
||||
<div class="f9-alert f9-alert--warn">
|
||||
<strong>Check before filing.</strong>
|
||||
<ul class="f9-caveats">
|
||||
@for (caveat of data.caveats; track caveat) {
|
||||
<li>{{ caveat }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ═══ A. Liabilities ═══ -->
|
||||
<table class="f9-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="f9-table__particulars">A. Liabilities in India</th>
|
||||
<th class="f9-table__num">Rs. in Thousands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of data.liabilityLines; track line.configKey) {
|
||||
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
|
||||
<td class="f9-table__particulars">
|
||||
{{ line.label }}
|
||||
@if (!line.configured) {
|
||||
<span class="f9-badge">Not mapped</span>
|
||||
}
|
||||
<!-- Why a line reads as it does, where the figure alone would mislead — a loss
|
||||
mapped under Balance of Profit but reported on the asset side. -->
|
||||
@if (line.note) {
|
||||
<span class="f9-line__note">{{ line.note }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="f9-table__num">{{ amount(line.amount) }}</td>
|
||||
</tr>
|
||||
<!-- The accounts behind the head. On a "specify" head these rows ARE the sheet the
|
||||
RBI workbook asks for — one row per account, under its own name. -->
|
||||
@for (account of line.accounts; track $index) {
|
||||
<tr class="f9-subline" [class.f9-subline--excluded]="account.excluded">
|
||||
<td>
|
||||
<span class="f9-subline__code">{{ account.glCode }}</span>
|
||||
{{ account.name }}
|
||||
@if (account.excluded) {
|
||||
<span class="f9-subline__excluded">excluded — {{ account.excluded }}</span>
|
||||
}
|
||||
@if (isPartial(account)) {
|
||||
<span class="f9-subline__portion">{{ account.weightPercentage }}% share</span>
|
||||
}
|
||||
</td>
|
||||
<td class="f9-table__num">{{ amount(account.balance) }}</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
<tr class="f9-total">
|
||||
<td>Total Demand and Time Liabilities (4 + 5 + 6)</td>
|
||||
<td class="f9-table__num">{{ amount(total('totalDemandAndTime')) }}</td>
|
||||
</tr>
|
||||
<tr class="f9-total f9-total--grand">
|
||||
<td>TOTAL LIABILITIES</td>
|
||||
<td class="f9-table__num">{{ amount(total('totalLiabilities')) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ═══ B. Assets ═══ -->
|
||||
<table class="f9-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="f9-table__particulars">B. Assets in India</th>
|
||||
<th class="f9-table__num">Rs. in Thousands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of data.assetLines; track line.configKey) {
|
||||
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
|
||||
<td class="f9-table__particulars">
|
||||
{{ line.label }}
|
||||
@if (!line.configured) {
|
||||
<span class="f9-badge">Not mapped</span>
|
||||
}
|
||||
<!-- Why a line reads as it does, where the figure alone would mislead — a loss
|
||||
mapped under Balance of Profit but reported on the asset side. -->
|
||||
@if (line.note) {
|
||||
<span class="f9-line__note">{{ line.note }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="f9-table__num">{{ amount(line.amount) }}</td>
|
||||
</tr>
|
||||
<!-- A head whose sub-rows carry the opposite sign says so on its own line. Three
|
||||
negative balances under a positive total otherwise read as an error rather than
|
||||
as the ledger side the figure was carried from. -->
|
||||
@if (line.accountsMirrorHead && line.accounts.length) {
|
||||
<tr class="f9-subline f9-subline--mirror">
|
||||
<td colspan="2">
|
||||
As the ledger holds them — these balances carry the opposite sign and add to the figure above
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@for (account of line.accounts; track $index) {
|
||||
<tr class="f9-subline" [class.f9-subline--excluded]="account.excluded">
|
||||
<td>
|
||||
<span class="f9-subline__code">{{ account.glCode }}</span>
|
||||
{{ account.name }}
|
||||
@if (account.excluded) {
|
||||
<span class="f9-subline__excluded">excluded — {{ account.excluded }}</span>
|
||||
}
|
||||
@if (isPartial(account)) {
|
||||
<span class="f9-subline__portion">{{ account.weightPercentage }}% share</span>
|
||||
}
|
||||
</td>
|
||||
<td class="f9-table__num">{{ amount(account.balance) }}</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
<tr class="f9-total f9-total--grand">
|
||||
<td>TOTAL ASSETS</td>
|
||||
<td class="f9-table__num">{{ amount(total('totalAssets')) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ═══ Stated splits and explanations ═══ -->
|
||||
@if (data.memoLines.length) {
|
||||
<!-- ═══ A. Liabilities ═══ -->
|
||||
<table class="f9-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="f9-table__particulars">Stated splits and explanations — entering no total</th>
|
||||
<th class="f9-table__particulars">A. Liabilities in India</th>
|
||||
<th class="f9-table__num">Rs. in Thousands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of data.memoLines; track line.configKey) {
|
||||
@for (line of data.liabilityLines; track line.configKey) {
|
||||
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
|
||||
<td class="f9-table__particulars">
|
||||
{{ line.label }}
|
||||
@if (!line.configured) {
|
||||
<span class="f9-badge">Not mapped</span>
|
||||
}
|
||||
<!-- Why a line reads as it does, where the figure alone would mislead — a loss
|
||||
mapped under Balance of Profit but reported on the asset side. -->
|
||||
@if (line.note) {
|
||||
<span class="f9-line__note">{{ line.note }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="f9-table__num">{{ amount(line.amount) }}</td>
|
||||
</tr>
|
||||
<!-- The accounts behind the head. On a "specify" head these rows ARE the sheet the
|
||||
RBI workbook asks for — one row per account, under its own name. -->
|
||||
@for (account of line.accounts; track $index) {
|
||||
<tr class="f9-subline" [class.f9-subline--excluded]="account.excluded">
|
||||
<td>
|
||||
<span class="f9-subline__code">{{ account.glCode }}</span>
|
||||
{{ account.name }}
|
||||
@if (account.excluded) {
|
||||
<span class="f9-subline__excluded">excluded — {{ account.excluded }}</span>
|
||||
}
|
||||
@if (isPartial(account)) {
|
||||
<span class="f9-subline__portion">{{ account.weightPercentage }}% share</span>
|
||||
}
|
||||
</td>
|
||||
<td class="f9-table__num">{{ amount(account.balance) }}</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
<tr class="f9-line">
|
||||
<td class="f9-table__particulars">Percentage of overdues to total loans and advances</td>
|
||||
<td class="f9-table__num">{{ percent(total('overduePercentage')) }}</td>
|
||||
<tr class="f9-total">
|
||||
<td>Total Demand and Time Liabilities (4 + 5 + 6)</td>
|
||||
<td class="f9-table__num">{{ amount(total('totalDemandAndTime')) }}</td>
|
||||
</tr>
|
||||
<tr class="f9-line">
|
||||
<td class="f9-table__particulars">Priority sector advances as a percentage of total loans</td>
|
||||
<td class="f9-table__num">{{ percent(total('prioritySectorPercentage')) }}</td>
|
||||
<tr class="f9-total f9-total--grand">
|
||||
<td>TOTAL LIABILITIES</td>
|
||||
<td class="f9-table__num">{{ amount(total('totalLiabilities')) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<div class="f9-sign">
|
||||
<div class="f9-sign__line">AUTHORISED SIGNATORY</div>
|
||||
<!-- ═══ B. Assets ═══ -->
|
||||
<table class="f9-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="f9-table__particulars">B. Assets in India</th>
|
||||
<th class="f9-table__num">Rs. in Thousands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of data.assetLines; track line.configKey) {
|
||||
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
|
||||
<td class="f9-table__particulars">
|
||||
{{ line.label }}
|
||||
@if (!line.configured) {
|
||||
<span class="f9-badge">Not mapped</span>
|
||||
}
|
||||
<!-- Why a line reads as it does, where the figure alone would mislead — a loss
|
||||
mapped under Balance of Profit but reported on the asset side. -->
|
||||
@if (line.note) {
|
||||
<span class="f9-line__note">{{ line.note }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="f9-table__num">{{ amount(line.amount) }}</td>
|
||||
</tr>
|
||||
<!-- A head whose sub-rows carry the opposite sign says so on its own line. Three
|
||||
negative balances under a positive total otherwise read as an error rather than
|
||||
as the ledger side the figure was carried from. -->
|
||||
@if (line.accountsMirrorHead && line.accounts.length) {
|
||||
<tr class="f9-subline f9-subline--mirror">
|
||||
<td colspan="2">
|
||||
As the ledger holds them — these balances carry the opposite sign and add to the figure above
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@for (account of line.accounts; track $index) {
|
||||
<tr class="f9-subline" [class.f9-subline--excluded]="account.excluded">
|
||||
<td>
|
||||
<span class="f9-subline__code">{{ account.glCode }}</span>
|
||||
{{ account.name }}
|
||||
@if (account.excluded) {
|
||||
<span class="f9-subline__excluded">excluded — {{ account.excluded }}</span>
|
||||
}
|
||||
@if (isPartial(account)) {
|
||||
<span class="f9-subline__portion">{{ account.weightPercentage }}% share</span>
|
||||
}
|
||||
</td>
|
||||
<td class="f9-table__num">{{ amount(account.balance) }}</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
<tr class="f9-total f9-total--grand">
|
||||
<td>TOTAL ASSETS</td>
|
||||
<td class="f9-table__num">{{ amount(total('totalAssets')) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ═══ Stated splits and explanations ═══ -->
|
||||
@if (data.memoLines.length) {
|
||||
<table class="f9-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="f9-table__particulars">Stated splits and explanations — entering no total</th>
|
||||
<th class="f9-table__num">Rs. in Thousands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of data.memoLines; track line.configKey) {
|
||||
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
|
||||
<td class="f9-table__particulars">
|
||||
{{ line.label }}
|
||||
@if (!line.configured) {
|
||||
<span class="f9-badge">Not mapped</span>
|
||||
}
|
||||
</td>
|
||||
<td class="f9-table__num">{{ amount(line.amount) }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="f9-line">
|
||||
<td class="f9-table__particulars">Percentage of overdues to total loans and advances</td>
|
||||
<td class="f9-table__num">{{ percent(total('overduePercentage')) }}</td>
|
||||
</tr>
|
||||
<tr class="f9-line">
|
||||
<td class="f9-table__particulars">Priority sector advances as a percentage of total loans</td>
|
||||
<td class="f9-table__num">{{ percent(total('prioritySectorPercentage')) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<div class="f9-sign">
|
||||
<div class="f9-sign__line">AUTHORISED SIGNATORY</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<any> {
|
||||
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<any> {
|
||||
return this.http.post('/rbireservereturn/transfer', transfer);
|
||||
}
|
||||
|
||||
// ── Saved returns ──
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,7 +712,17 @@
|
||||
}
|
||||
</td>
|
||||
@for (v of line.values; track $index) {
|
||||
<td class="f1s-table__num">{{ amount(v) }}</td>
|
||||
<!-- Resting on a figure lists the GL accounts it was made of, and the share of
|
||||
each one's balance taken, so a reviewer can trace it back to the ledger. -->
|
||||
<td
|
||||
class="f1s-table__num"
|
||||
[class.f1s-has-tip]="line.sources?.[$index]?.length || line.formula"
|
||||
(mouseenter)="showTip($event, line, $index, statutory.columns[$index], v)"
|
||||
(mousemove)="moveTip($event)"
|
||||
(mouseleave)="hideTip()"
|
||||
>
|
||||
{{ thousands(v) }}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
<!--
|
||||
@@ -729,7 +739,7 @@
|
||||
<tr class="f1s-total f1s-total--result">
|
||||
<td>Surplus / (Shortfall)</td>
|
||||
@for (s of part.surplus; track $index) {
|
||||
<td class="f1s-table__num" [class.f1s-neg]="isShort(s)">{{ amount(s) }}</td>
|
||||
<td class="f1s-table__num" [class.f1s-neg]="isShort(s)">{{ thousands(s) || '–' }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
@@ -742,6 +752,104 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (tip) {
|
||||
<div class="f1s-tip rr-noprint" [style.left.px]="tip.x" [style.top.px]="tip.y">
|
||||
<div class="f1s-tip__title">{{ tip.label }}</div>
|
||||
<div class="f1s-tip__sub">As on {{ tip.column }} · {{ rupees(tip.value) }}</div>
|
||||
@if (tip.sources.length) {
|
||||
<table class="f1s-tip__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL</th>
|
||||
<th class="f1s-tip__num">Balance</th>
|
||||
<th class="f1s-tip__num">Taken</th>
|
||||
<th class="f1s-tip__num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (src of tip.sources; track $index) {
|
||||
<tr [class.f1s-tip__out]="src.excluded">
|
||||
<td>
|
||||
<strong>{{ src.glCode }}</strong> {{ src.name }}
|
||||
@if (src.excluded) {
|
||||
<em>(left out: {{ src.excluded }})</em>
|
||||
}
|
||||
</td>
|
||||
<td class="f1s-tip__num">{{ rupees(src.accountBalance) }}</td>
|
||||
<td class="f1s-tip__num">{{ src.weightPercentage ?? 100 }}%</td>
|
||||
<td class="f1s-tip__num">{{ src.excluded ? '—' : rupees(src.amount) }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@if (tip.formula) {
|
||||
<div class="f1s-tip__formula">{{ tip.formula }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
═══ Part C.XII.c — the securities behind item XII(c) ═══
|
||||
The compliance sheet above states one figure for securities held. This is the working
|
||||
behind it: what the holding opened at, what was bought and sold inside the fortnight, and
|
||||
what it closed at. Face value and book value are both the ledger's carrying amount and
|
||||
depreciation is nil — a securities GL holds one figure, already net of any provision made
|
||||
against it, so a depreciation column off the investment reserve would deduct it twice.
|
||||
-->
|
||||
@if (statutory.securities?.sections?.length) {
|
||||
<div class="f1s-sec">
|
||||
<h3 class="f1s-sec__title">{{ statutory.securities!.title }}</h3>
|
||||
<div class="f1s-scroll">
|
||||
<table class="f1s-table f1s-sec__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" class="f1s-table__particulars">Particulars</th>
|
||||
@for (c of statutory.columns; track $index) {
|
||||
<th colspan="4" class="f1s-sec__date">{{ c }}</th>
|
||||
}
|
||||
</tr>
|
||||
<tr>
|
||||
@for (c of statutory.columns; track $index) {
|
||||
<th class="f1s-table__num f1s-sec__open">Face Value <span class="f1s-sec__no">1</span></th>
|
||||
<th class="f1s-table__num">Book Value <span class="f1s-sec__no">2</span></th>
|
||||
<th class="f1s-table__num">Depreciation <span class="f1s-sec__no">3</span></th>
|
||||
<th class="f1s-table__num f1s-sec__net">Net Value for SLR <span class="f1s-sec__no">2-3</span></th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (section of statutory.securities!.sections; track section.configKey) {
|
||||
<tr class="f1s-heading f1s-sec__part">
|
||||
<td [attr.colspan]="statutory.columns.length * 4 + 1">{{ section.title }}</td>
|
||||
</tr>
|
||||
@for (row of securityRows(section); track row.label) {
|
||||
<tr [class.f1s-total]="row.closing">
|
||||
<td class="f1s-table__particulars f1s-sec__row">{{ row.label }}</td>
|
||||
@for (v of row.values; track $index) {
|
||||
<td class="f1s-table__num f1s-sec__open">{{ thousands(v) || '–' }}</td>
|
||||
<td class="f1s-table__num">{{ thousands(v) || '–' }}</td>
|
||||
<td class="f1s-table__num f1s-sec__nil">–</td>
|
||||
<td class="f1s-table__num f1s-sec__net">{{ thousands(v) || '–' }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
<tr class="f1s-total f1s-sec__grand">
|
||||
<td class="f1s-table__particulars">Closing Balance (a+b)</td>
|
||||
@for (v of statutory.securities!.closing; track $index) {
|
||||
<td class="f1s-table__num f1s-sec__open">{{ thousands(v) || '–' }}</td>
|
||||
<td class="f1s-table__num">{{ thousands(v) || '–' }}</td>
|
||||
<td class="f1s-table__num f1s-sec__nil">–</td>
|
||||
<td class="f1s-table__num f1s-sec__net">{{ thousands(v) || '–' }}</td>
|
||||
}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
═══ Day by day ═══
|
||||
Both ratios are reckoned over the period, not on the two closes alone — a bank that held
|
||||
@@ -770,16 +878,16 @@
|
||||
<div class="f1s-daily__stats">
|
||||
<div class="f1s-stat">
|
||||
<span class="f1s-stat__label">CRR average held</span>
|
||||
<span class="f1s-stat__value">{{ amount(statutory.daily!.crrAverage) }}</span>
|
||||
<span class="f1s-stat__value">{{ thousands(statutory.daily!.crrAverage) }}</span>
|
||||
<span class="f1s-stat__sub">
|
||||
lowest {{ amount(statutory.daily!.crrLowest) }} on {{ statutory.daily!.crrLowestOn }}
|
||||
lowest {{ thousands(statutory.daily!.crrLowest) }} on {{ statutory.daily!.crrLowestOn }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="f1s-stat">
|
||||
<span class="f1s-stat__label">SLR average held</span>
|
||||
<span class="f1s-stat__value">{{ amount(statutory.daily!.slrAverage) }}</span>
|
||||
<span class="f1s-stat__value">{{ thousands(statutory.daily!.slrAverage) }}</span>
|
||||
<span class="f1s-stat__sub">
|
||||
lowest {{ amount(statutory.daily!.slrLowest) }} on {{ statutory.daily!.slrLowestOn }}
|
||||
lowest {{ thousands(statutory.daily!.slrLowest) }} on {{ statutory.daily!.slrLowestOn }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="f1s-stat">
|
||||
@@ -789,54 +897,98 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="f1s-table f1s-daily__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" class="f1s-table__particulars">Date</th>
|
||||
<!-- The liabilities, before the ratios. Two of them, because they are not the same
|
||||
figure: the day's own move daily, the base holds still for the whole fortnight,
|
||||
and it is the base the requirement is charged on. Without both, a table of
|
||||
identical rows gives no way to see a day the bank's liabilities moved. -->
|
||||
<th colspan="3" class="f1s-table__num">Net demand & time liabilities</th>
|
||||
<th colspan="3" class="f1s-table__num">Section 18 — CRR</th>
|
||||
<th colspan="3" class="f1s-table__num">Section 24 — SLR</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="f1s-table__num">On the day</th>
|
||||
<th class="f1s-table__num">Charged on</th>
|
||||
<th class="f1s-table__num">Base amount</th>
|
||||
<th class="f1s-table__num">Required</th>
|
||||
<th class="f1s-table__num">Maintained</th>
|
||||
<th class="f1s-table__num">Surplus / (short)</th>
|
||||
<th class="f1s-table__num">Required</th>
|
||||
<th class="f1s-table__num">Maintained</th>
|
||||
<th class="f1s-table__num">Surplus / (short)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (day of statutory.daily!.days; track day.date) {
|
||||
<tr [class.f1s-daily--short]="day.crrSurplus < 0 || day.slrSurplus < 0">
|
||||
<td>{{ day.date }}</td>
|
||||
<!-- A day the bank's own liabilities moved is marked, because that is the thing
|
||||
this column exists to show and on thirty near-identical rows it is exactly
|
||||
what the eye goes past. -->
|
||||
<td class="f1s-table__num" [class.f1s-daily__moved]="dailyOwnMoved(day, $index)">
|
||||
{{ day.ownLiabilities === undefined ? '—' : amount(day.ownLiabilities) }}
|
||||
</td>
|
||||
<td class="f1s-table__num f1s-daily__base">{{ day.baseDate }}</td>
|
||||
<td class="f1s-table__num f1s-daily__base">
|
||||
{{ day.baseLiabilities === undefined ? '—' : amount(day.baseLiabilities) }}
|
||||
</td>
|
||||
<td class="f1s-table__num">{{ amount(day.crrRequired) }}</td>
|
||||
<td class="f1s-table__num">{{ amount(day.crrMaintained) }}</td>
|
||||
<td class="f1s-table__num" [class.f1s-neg]="day.crrSurplus < 0">{{ amount(day.crrSurplus) }}</td>
|
||||
<td class="f1s-table__num">{{ amount(day.slrRequired) }}</td>
|
||||
<td class="f1s-table__num">{{ amount(day.slrMaintained) }}</td>
|
||||
<td class="f1s-table__num" [class.f1s-neg]="day.slrSurplus < 0">{{ amount(day.slrSurplus) }}</td>
|
||||
<!-- Laid out as the CIMS template lays its daily sheets out: a serial number, the date,
|
||||
the base the day is charged on, then Required / Actual / Deficit / Surplus. The gap
|
||||
is split into two columns rather than carried as one signed figure because that is
|
||||
how the template reads it, and a filer copying between the two should not have to
|
||||
re-sign anything.
|
||||
|
||||
Eleven columns, so the sheet scrolls within itself on a narrow window rather than
|
||||
pushing the whole return sideways or silently cutting its right-hand columns off. -->
|
||||
<div class="f1s-scroll">
|
||||
<table class="f1s-table f1s-daily__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" class="f1s-daily__sr">Sr. No.</th>
|
||||
<th rowspan="2" class="f1s-table__particulars">Date</th>
|
||||
<th rowspan="2" class="f1s-table__num f1s-daily__base">
|
||||
Net Demand and Time Liabilities<br />
|
||||
<span class="f1s-daily__note">(as on second preceding fortnight)</span>
|
||||
</th>
|
||||
<th colspan="5" class="f1s-table__num f1s-daily__group">Cash Reserve Ratio Amount</th>
|
||||
<th colspan="5" class="f1s-table__num f1s-daily__group">Statutory Liquidity Ratio Amount</th>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<tr>
|
||||
<th class="f1s-table__num f1s-sec__open f1s-daily__rate">Rate</th>
|
||||
<th class="f1s-table__num">Required to be maintained</th>
|
||||
<th class="f1s-table__num">Actually maintained</th>
|
||||
<th class="f1s-table__num f1s-daily__deficit">
|
||||
Deficit<br /><span class="f1s-daily__note">(Required−Actual)</span>
|
||||
</th>
|
||||
<th class="f1s-table__num f1s-daily__surplus">
|
||||
Surplus<br /><span class="f1s-daily__note">(Actual−Required)</span>
|
||||
</th>
|
||||
<th class="f1s-table__num f1s-sec__open f1s-daily__rate">Rate</th>
|
||||
<th class="f1s-table__num">Required to be maintained</th>
|
||||
<th class="f1s-table__num">Actually maintained</th>
|
||||
<th class="f1s-table__num f1s-daily__deficit">
|
||||
Deficit<br /><span class="f1s-daily__note">(Required−Actual)</span>
|
||||
</th>
|
||||
<th class="f1s-table__num f1s-daily__surplus">
|
||||
Surplus<br /><span class="f1s-daily__note">(Actual−Required)</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (day of statutory.daily!.days; track day.date) {
|
||||
<tr [class.f1s-daily--short]="day.crrSurplus < 0 || day.slrSurplus < 0">
|
||||
<td class="f1s-daily__sr">{{ $index + 1 }}</td>
|
||||
<td class="f1s-daily__date">{{ day.date }}</td>
|
||||
<!-- The base, not the day's own liabilities: the requirement is charged on the
|
||||
fortnight before, and a column showing today's figure beside a requirement
|
||||
struck on another day's reads as arithmetic that does not add up. -->
|
||||
<td class="f1s-table__num f1s-daily__base" [title]="'Charged on ' + day.baseDate">
|
||||
{{ day.baseLiabilities === undefined ? '—' : thousands(day.baseLiabilities) }}
|
||||
</td>
|
||||
<!-- The rate that settled this day. A day the rate changed is marked, because on
|
||||
thirty rows carrying the same figure that is the one a reader must not miss:
|
||||
it is where the requirement steps, and every day after it is charged
|
||||
differently from every day before. -->
|
||||
<td
|
||||
class="f1s-table__num f1s-sec__open f1s-daily__rate"
|
||||
[class.f1s-daily__rate--new]="rateChangedOn(day, 'crr')"
|
||||
[title]="day.crrEffectiveFrom ? 'In force since ' + day.crrEffectiveFrom : ''"
|
||||
>
|
||||
{{ day.crrPercentage == null ? '—' : percent(day.crrPercentage) }}
|
||||
</td>
|
||||
<td class="f1s-table__num">{{ thousands(day.crrRequired) }}</td>
|
||||
<td class="f1s-table__num">{{ thousands(day.crrMaintained) }}</td>
|
||||
<td class="f1s-table__num f1s-daily__deficit">
|
||||
{{ day.crrSurplus < 0 ? thousands(-day.crrSurplus) : '–' }}
|
||||
</td>
|
||||
<td class="f1s-table__num f1s-daily__surplus">
|
||||
{{ day.crrSurplus > 0 ? thousands(day.crrSurplus) : '–' }}
|
||||
</td>
|
||||
<td
|
||||
class="f1s-table__num f1s-sec__open f1s-daily__rate"
|
||||
[class.f1s-daily__rate--new]="rateChangedOn(day, 'slr')"
|
||||
[title]="day.slrEffectiveFrom ? 'In force since ' + day.slrEffectiveFrom : ''"
|
||||
>
|
||||
{{ day.slrPercentage == null ? '—' : percent(day.slrPercentage) }}
|
||||
</td>
|
||||
<td class="f1s-table__num">{{ thousands(day.slrRequired) }}</td>
|
||||
<td class="f1s-table__num">{{ thousands(day.slrMaintained) }}</td>
|
||||
<td class="f1s-table__num f1s-daily__deficit">
|
||||
{{ day.slrSurplus < 0 ? thousands(-day.slrSurplus) : '–' }}
|
||||
</td>
|
||||
<td class="f1s-table__num f1s-daily__surplus">
|
||||
{{ day.slrSurplus > 0 ? thousands(day.slrSurplus) : '–' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -994,7 +1146,7 @@
|
||||
{{ line.label }}
|
||||
</td>
|
||||
@for (value of line.values || []; track $index) {
|
||||
<td class="f1s-table__num">{{ amount(value) }}</td>
|
||||
<td class="f1s-table__num">{{ thousands(value) }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+56
@@ -26,6 +26,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One branch's part of a transfer between branches: say so, name both branches, link the other part -->
|
||||
@if (interBranch) {
|
||||
<div class="txn-interbranch">
|
||||
<div class="txn-interbranch__icon"><fa-icon icon="link"></fa-icon></div>
|
||||
<div class="txn-interbranch__body">
|
||||
<div class="txn-interbranch__title">
|
||||
Inter-branch transfer
|
||||
<span class="txn-interbranch__voucher">{{ interBranch.voucherNo }}</span>
|
||||
</div>
|
||||
<div class="txn-interbranch__route">
|
||||
<strong>{{ branchesOf(interBranch.from) }}</strong>
|
||||
<span class="txn-interbranch__arrow">→</span>
|
||||
<strong>{{ branchesOf(interBranch.to) }}</strong>
|
||||
<span class="txn-interbranch__amount">{{ interBranch.amount | formatNumber }}</span>
|
||||
</div>
|
||||
<div class="txn-interbranch__text">
|
||||
This is the <strong>{{ thisBranchPart?.branchName }}</strong> 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.
|
||||
</div>
|
||||
<div class="txn-interbranch__legs">
|
||||
@for (leg of interBranch.from; track leg.accountNo) {
|
||||
<span class="txn-interbranch__leg"
|
||||
><b>From</b> {{ leg.accountNo }} · {{ leg.accountName }} ({{ leg.branchName }})</span
|
||||
>
|
||||
}
|
||||
@for (leg of interBranch.to; track leg.accountNo) {
|
||||
<span class="txn-interbranch__leg"
|
||||
><b>To</b> {{ leg.accountNo }} · {{ leg.accountName }} ({{ leg.branchName }})</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (interBranch.raisedBy) {
|
||||
<div class="txn-interbranch__text">
|
||||
Raised by <strong>{{ interBranch.raisedBy }}</strong> on {{ interBranch.raisedAt | datetimeFormat }},
|
||||
approved by <strong>{{ interBranch.approvedBy }}</strong> on
|
||||
{{ interBranch.approvedAt | datetimeFormat }}.
|
||||
</div>
|
||||
}
|
||||
<div class="txn-interbranch__text">
|
||||
@for (part of otherBranchParts; track part.transactionId) {
|
||||
The <strong>{{ part.branchName }}</strong> part:
|
||||
<button type="button" class="txn-interbranch__link" (click)="openTransaction(part.transactionId)">
|
||||
{{ part.transactionId }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="txn-interbranch__warn">
|
||||
<fa-icon icon="exclamation-triangle" class="m-r-6"></fa-icon>
|
||||
Do not revert one branch's part on its own: the other branch's part would stay and the books would not
|
||||
balance.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Transaction Info Cards -->
|
||||
@if (isViewTransaction()) {
|
||||
@if (dataSource) {
|
||||
|
||||
+92
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+39
-1
@@ -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';
|
||||
}
|
||||
|
||||
@@ -67,9 +67,11 @@ import { SponsorDDService } from '../sponsor-dd.service';
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>DD Charges / Commission Income GL *</mat-label>
|
||||
<mat-select formControlName="chargesIncomeGlAccountId" required placeholder="Select Charges Income GL">
|
||||
<mat-option *ngFor="let acc of glAccounts" [value]="acc.id"> {{ acc.glCode }} · {{ acc.name }} </mat-option>
|
||||
<mat-option *ngFor="let acc of incomeAccounts" [value]="acc.id">
|
||||
{{ acc.glCode }} · {{ acc.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Credited for DD Issuance Charges/Commission</mat-hint>
|
||||
<mat-hint>Income accounts only — credited for DD Issuance Charges/Commission</mat-hint>
|
||||
<mat-error *ngIf="form.get('chargesIncomeGlAccountId')?.hasError('required')">Required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
@@ -77,9 +79,12 @@ import { SponsorDDService } from '../sponsor-dd.service';
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>GST Payable on Charges GL *</mat-label>
|
||||
<mat-select formControlName="gstPayableGlAccountId" required placeholder="Select GST Liability GL">
|
||||
<mat-option *ngFor="let acc of glAccounts" [value]="acc.id"> {{ acc.glCode }} · {{ acc.name }} </mat-option>
|
||||
<mat-option *ngFor="let acc of liabilityAccounts" [value]="acc.id">
|
||||
{{ acc.glCode }} · {{ acc.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Credited for GST collected on charges</mat-hint>
|
||||
<!-- GST is owed to the government. An income account here is swept into profit at year end. -->
|
||||
<mat-hint>Liability accounts only — GST is owed to the government, not the bank's income</mat-hint>
|
||||
<mat-error *ngIf="form.get('gstPayableGlAccountId')?.hasError('required')">Required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
@@ -87,12 +92,29 @@ import { SponsorDDService } from '../sponsor-dd.service';
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Cash at Tellers / Vault GL Account *</mat-label>
|
||||
<mat-select formControlName="cashGlAccountId" required placeholder="Select Cash Asset GL">
|
||||
<mat-option *ngFor="let acc of glAccounts" [value]="acc.id"> {{ acc.glCode }} · {{ acc.name }} </mat-option>
|
||||
<mat-option *ngFor="let acc of assetAccounts" [value]="acc.id">
|
||||
{{ acc.glCode }} · {{ acc.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Debited when cash is received from walk-in customer</mat-hint>
|
||||
<mat-error *ngIf="form.get('cashGlAccountId')?.hasError('required')">Required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Cash Suspense GL Account -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>DD Cash Suspense GL Account *</mat-label>
|
||||
<mat-select formControlName="cashSuspenseGlAccountId" required placeholder="Select Cash Suspense GL">
|
||||
<mat-option *ngFor="let acc of suspenseAccounts" [value]="acc.id">
|
||||
{{ acc.glCode }} · {{ acc.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Holds a walk-in's cash until the DD is approved — must differ from the Cash GL</mat-hint>
|
||||
<mat-error *ngIf="form.get('cashSuspenseGlAccountId')?.hasError('required')">Required</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-error *ngIf="form.hasError('suspenseSameAsCash')" class="form-error">
|
||||
Cash Suspense GL must be a different account from the Cash GL.
|
||||
</mat-error>
|
||||
|
||||
<!-- Cheque Clearing GL Account -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Cheque Clearing Suspense GL Account</mat-label>
|
||||
@@ -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
|
||||
|
||||
+25
-1
@@ -121,7 +121,17 @@
|
||||
@for (r of requests; track r.id) {
|
||||
<tr class="dri-row" [class.dri-row--approved]="r.status === 'APPROVED'" (click)="review(r)">
|
||||
<td class="cell-mono dri-voucher">{{ r.voucher_no || '—' }}</td>
|
||||
<td class="cell-mono">{{ r.account_no }}</td>
|
||||
<td class="cell-mono">
|
||||
{{ r.account_no }}
|
||||
@if (r.inter_branch) {
|
||||
<span
|
||||
class="dri-branch-badge"
|
||||
[title]="'Cash taken at ' + r.counter_office_name + ', account held at ' + r.account_office_name"
|
||||
>
|
||||
Inter-branch · {{ r.counter_office_name }} → {{ r.account_office_name }}
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="dri-client">{{ r.client_name }}</td>
|
||||
<td class="num dri-amount">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ r.payment_type_name || 'Cash' }}</td>
|
||||
@@ -173,6 +183,20 @@
|
||||
</div>
|
||||
|
||||
<div class="dri-modal__body">
|
||||
@if (reviewRequest.inter_branch) {
|
||||
<div class="dri-interbranch">
|
||||
<mat-icon>alt_route</mat-icon>
|
||||
<div>
|
||||
<div class="dri-interbranch__title">Inter-branch deposit</div>
|
||||
<div class="dri-interbranch__text">
|
||||
Cash was received at <strong>{{ reviewRequest.counter_office_name }}</strong> but the account is held at
|
||||
<strong>{{ reviewRequest.account_office_name }}</strong
|
||||
>. The customer's account is credited in {{ reviewRequest.account_office_name }}'s books. Check the
|
||||
account before approving.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="dri-detail-grid">
|
||||
<div class="dri-detail">
|
||||
<span class="dri-detail__lbl">Account No</span>
|
||||
|
||||
+45
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user