This commit is contained in:
2026-08-02 14:23:46 +05:30
37 changed files with 9292 additions and 620 deletions
+98
View File
@@ -0,0 +1,98 @@
/**
* 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 { AuthenticationService } from './authentication/authentication.service';
import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data';
/**
* Resolves what branch data the logged-in user is allowed to see.
*
* Head Office sees every branch and can drill into any one of them; a branch login is
* locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing
* Summary and the NPA report with slightly different branch code lists; this service is
* the single place it lives so new reports cannot drift.
*
* The logged-in office is matched to a branch by name, since session credentials carry
* only officeId and officeName. Office names are seeded to match the GL branch names
* exactly (see Liquibase 0442).
*/
@Injectable({ providedIn: 'root' })
export class BranchScopeService {
private auth = inject(AuthenticationService);
/** Sentinel used when a branch login cannot be matched to a known branch. */
static readonly UNRESOLVED = '__unresolved__';
get isHeadOffice(): boolean {
return this.auth.isHeadOfficeUser();
}
/** The office name on the session, for labelling the scope in report headers. */
get officeName(): string {
return this.auth.getCredentials()?.officeName || 'Branch Office';
}
/**
* Branch code the user is confined to, or null for Head Office.
*
* Deliberately returns the UNRESOLVED sentinel rather than falling back to the first
* branch when the office cannot be matched: showing an unrecognised branch someone
* else's figures would be worse than showing them nothing.
*/
get ownBranchCode(): string | null {
if (this.isHeadOffice) {
return null;
}
const name = this.officeName.toLowerCase().trim();
const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase());
// Fall back to a substring match for offices named e.g. "Andheri (E) Branch".
const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase()));
return loose?.code ?? BranchScopeService.UNRESOLVED;
}
/** True when the user may choose which branch to view. */
get canSelectBranch(): boolean {
return this.isHeadOffice;
}
/** Branches offered in a selector — everything for Head Office, nothing for a branch login. */
get selectableBranches(): GlBranch[] {
return this.isHeadOffice ? GL_BRANCHES : [];
}
/** Branch code a report should start on. '' means all branches consolidated. */
get defaultBranchCode(): string {
return this.isHeadOffice ? '' : (this.ownBranchCode ?? '');
}
/**
* Forces a requested branch code into what the user is actually allowed to see.
* Call this wherever a branch code reaches a data query, so a stale or hand-edited
* selection cannot widen the user's scope.
*/
enforce(requested: string): string {
return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED);
}
/** True when a branch login's office matched no known branch, so it sees no data. */
get isUnresolved(): boolean {
return this.ownBranchCode === BranchScopeService.UNRESOLVED;
}
/** Human label for the current scope, for report headers. */
scopeLabel(selectedCode: string): string {
if (!this.isHeadOffice) {
return this.officeName;
}
if (!selectedCode) {
return 'All Branches (Consolidated)';
}
return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode;
}
}
+48 -14
View File
@@ -587,6 +587,10 @@
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
<mat-divider></mat-divider>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -865,20 +869,34 @@
</div>
<div class="gl-group">
<div class="gl-group-title">GL Reports</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false"
><fa-icon icon="calculator" class="gl-icon"></fa-icon> GL Average Balance</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('GL Average Balance')"
routerLink="/gl/reports/gl-average-balance"
(click)="glMenuOpen = false"
><fa-icon icon="calculator" class="gl-icon" style="color: #b45309"></fa-icon> GL Average Balance</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Mapping for Report')" (click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon"></fa-icon> GL Mapping for Report</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('GL Mapping for Report')"
routerLink="/gl/reports/gl-mapping"
(click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Mapping for
Report</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Account wise Balance List')"
routerLink="/gl/reports/account-wise-balance"
(click)="glMenuOpen = false"
><fa-icon icon="list" class="gl-icon"></fa-icon> Account wise Balance List</a
><fa-icon icon="list" class="gl-icon" style="color: #b45309"></fa-icon> Account wise Balance List</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Distribution of Balance')" (click)="glMenuOpen = false"
><fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Distribution of Balance</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Distribution of Balance')"
routerLink="/gl/reports/distribution-of-balance"
(click)="glMenuOpen = false"
><fa-icon icon="chart-pie" class="gl-icon" style="color: #b45309"></fa-icon> Distribution of Balance</a
>
<a
class="gl-link"
@@ -894,8 +912,9 @@
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Client wise Balance Report')"
routerLink="/gl/reports/client-wise-balance"
(click)="glMenuOpen = false"
><fa-icon icon="users" class="gl-icon"></fa-icon> Client wise Balance Report</a
><fa-icon icon="users" class="gl-icon" style="color: #b45309"></fa-icon> Client wise Balance Report</a
>
<a
class="gl-link"
@@ -905,17 +924,29 @@
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
Liabilities</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Banker Head Report')"
routerLink="/gl/reports/banker-head"
(click)="glMenuOpen = false"
><fa-icon icon="university" class="gl-icon" style="color: #b45309"></fa-icon> Banker Head Report</a
>
</div>
<div class="gl-group">
<div class="gl-group-title">Registers</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false"
><fa-icon icon="clipboard-list" class="gl-icon"></fa-icon> Sundry Register</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Sundry Register')"
routerLink="/gl/reports/sundry-register"
(click)="glMenuOpen = false"
><fa-icon icon="clipboard-list" class="gl-icon" style="color: #b45309"></fa-icon> Sundry Register</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Voucher Register')" (click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Voucher Register')"
routerLink="/gl/reports/voucher-register"
(click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Voucher Register</a
>
<a
class="gl-link"
@@ -1565,6 +1596,9 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
</button>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 97/98 Txn Report
</button>
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
</button>
+24 -2
View File
@@ -20,7 +20,15 @@
*/
export type ReportType = 'CTR' | 'STR' | 'CCR' | 'NTR' | 'CBWTR';
export type StrStatus = 'draft' | 'po-review' | 'filed' | 'rejected';
/**
* Lifecycle of an STR case.
* draft — raised, not yet with the Principal Officer
* po-review — under Principal Officer investigation
* awaiting-filing — concluded suspicious; STR XML must still be submitted to FIU-IND
* filed — submitted and FIU-IND acknowledgement recorded
* rejected — concluded clean; nothing filed
*/
export type StrStatus = 'draft' | 'po-review' | 'awaiting-filing' | 'filed' | 'rejected';
/** TC2 — Branch / cash transaction record */
export interface CtrTransaction {
@@ -42,7 +50,17 @@ export interface CtrTransaction {
declaration: string;
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
customerName?: string;
complianceStatus?: 'PENDING' | 'WHITELISTED' | 'SUSPICIOUS';
/**
* 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
* case the transaction is referred to STR. The suspicious/clean verdict itself
* belongs to the Principal Officer on the STR side.
*/
complianceStatus?: 'PENDING' | 'EVIDENCED' | 'REFERRED';
/** Document that evidenced the source of funds, when verified at the counter. */
evidenceDocType?: string;
evidenceReference?: string;
evidencedOn?: string;
}
/** Outcome of the Principal Officer's investigation into an STR case */
@@ -64,6 +82,10 @@ export interface StrTransaction extends CtrTransaction {
poRemarks?: string;
/** Set once the Principal Officer concludes the investigation */
poFinding?: PoFinding;
/** FIU-IND acknowledgement number, recorded only after the STR is actually submitted */
fiuReferenceNo?: string;
/** Date the STR was submitted on the FINnet portal */
filedOn?: string;
}
/** KC1 — Individual customer card (subset of the 70+ fields, only what UI shows) */
+274 -47
View File
@@ -141,7 +141,8 @@
<div class="fiu-form-title">
<h3>Live CTR Threshold Alerts</h3>
<p>
Mechanical cash transaction threshold breaches — review and whitelist or escalate to Principal Officer
Mechanical cash threshold breaches. CTR is filed regardless of outcome — where the source of funds cannot be
evidenced, send the transaction to STR for the Principal Officer to investigate.
</p>
</div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
@@ -198,8 +199,7 @@
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Send to STR</button>
</div>
}
</td>
@@ -213,9 +213,9 @@
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot;
<span class="report-pill report-ctr">CTR only</span> whitelisted — cleared, no STR needed &middot;
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
<span class="report-pill report-pending">Pending</span> cash breach awaiting review &middot;
<span class="report-pill report-str">Referred to STR</span> source of funds not evidenced — sent to the
Principal Officer, who decides there whether to whitelist it or file an STR
</p>
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
@@ -347,32 +347,75 @@
</td>
<td class="ta-c">
<span class="report-pill report-ctr" title="Always included in the monthly CTR / TC2 file">CTR</span>
@if (t.complianceStatus === 'SUSPICIOUS') {
<span class="report-pill report-str" title="Also escalated to an STR case for Principal Officer review">+ STR</span>
@if (t.complianceStatus === 'REFERRED') {
<span
class="report-pill report-str"
title="Also under investigation as an STR case — the transaction remains in the CTR file"
>+ STR</span
>
}
</td>
<td>
@if (!t.complianceStatus || t.complianceStatus === 'PENDING') {
<div class="fiu-action-group" style="gap: 4px;">
<button class="fiu-btn" style="padding: 4px 8px; font-size: 11px; color: var(--f-green); border-color: var(--f-green); background: var(--f-green-l);" (click)="whitelistCtrTxn(t)">
Whitelist
</button>
<button class="fiu-btn" style="padding: 4px 8px; font-size: 11px; color: var(--f-red); border-color: var(--f-red); background: var(--f-red-l);" (click)="flagCtrTxnSuspicious(t)">
Flag STR
</button>
@if (t.complianceStatus === 'REFERRED') {
<span class="status-pill status-escalated">REFERRED TO STR</span>
<button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab &rarr;</button>
} @else if (t.complianceStatus === 'EVIDENCED') {
<span class="status-pill status-whitelisted">EVIDENCE ON FILE</span>
<div class="muted" style="margin-top: 3px">
{{ t.evidenceDocType }}
@if (t.evidenceReference) {
<span> · {{ t.evidenceReference }}</span>
}
</div>
} @else {
<span [class]="t.complianceStatus === 'WHITELISTED' ? 'status-pill status-whitelisted' : 'status-pill status-escalated'">
{{ t.complianceStatus }}
</span>
@if (t.complianceStatus === 'WHITELISTED') {
<div class="muted" style="margin-top: 3px;">Stays in CTR only</div>
} @else {
<button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab &rarr;</button>
}
<div class="fiu-action-group" style="gap: 4px" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-evidence" (click)="startCtrEvidence(t)">Record evidence</button>
<button class="fiu-btn fiu-btn-refer" (click)="referCtrTxnToStr(t)">Send to STR</button>
</div>
}
</td>
</tr>
<!-- Inline evidence capture for this transaction -->
@if (evidencingTxn?.txnId === t.txnId) {
<tr class="evidence-row">
<td colspan="12">
<div class="evidence-panel">
<div class="evidence-t">
<fa-icon icon="check-circle"></fa-icon>
Record source-of-funds evidence for {{ t.customerName }} &mdash; &#8377;{{
t.txnAmount | number: '1.0-0'
}}
</div>
<div class="evidence-grid">
<div class="form-row">
<label for="ctrDoc{{ t.txnId }}">Document produced</label>
<select id="ctrDoc{{ t.txnId }}" class="fiu-inp" [(ngModel)]="ctrEvidenceDocType">
<option value="">Select document…</option>
@for (d of evidenceDocTypes; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</div>
<div class="form-row">
<label for="ctrRef{{ t.txnId }}">Document / reference number</label>
<input id="ctrRef{{ t.txnId }}" class="fiu-inp" type="text" [(ngModel)]="ctrEvidenceRef" />
</div>
<div class="fiu-action-group">
<button class="fiu-btn fiu-btn-evidence" (click)="confirmCtrEvidence()">
Confirm — CTR only
</button>
<button class="fiu-btn" (click)="cancelCtrEvidence()">Cancel</button>
</div>
</div>
<p class="evidence-note">
Recording evidence is a documentation step, not a suspicion verdict. The transaction is reported
in the CTR either way — this simply confirms no STR referral is needed.
</p>
</div>
</td>
</tr>
}
} @empty {
<tr>
<td colspan="11" class="fiu-empty">No transactions match the filters.</td>
@@ -382,10 +425,11 @@
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold stays in the monthly CTR / TC2
file &middot;
<span class="report-pill report-str">+ STR</span> also flagged as suspicious and escalated to a separate STR
case (see the STR tab)
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold is reported here regardless of
outcome. <strong>Record evidence</strong> when the customer produces valid documents — CTR only, no STR &middot;
<span class="report-pill report-str">+ STR</span> <strong>Send to STR</strong> when the source of funds cannot
be evidenced; the Principal Officer investigates and decides whether to file with FIU-IND. A referred
transaction stays in the CTR file as well.
</p>
</div>
}
@@ -397,9 +441,7 @@
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Live AML Alerts (Awaiting Triage)</h3>
<p>
Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate
</p>
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
</div>
</div>
<div class="fiu-tbl-wrap">
@@ -468,7 +510,7 @@
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0;" />
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta">
@@ -627,23 +669,26 @@
} @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.
<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) {
{{ 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') : '…' }}
Conclude as
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
</button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
@@ -675,8 +720,9 @@
<option value="">All</option>
<option value="draft">Draft</option>
<option value="po-review">PO Review</option>
<option value="awaiting-filing">Awaiting Filing</option>
<option value="filed">Filed</option>
<option value="rejected">Rejected</option>
<option value="rejected">Closed — Clean</option>
</select>
</div>
<div class="fiu-filter">
@@ -752,17 +798,198 @@
</label>
<p>{{ poFindingLabel(s.poFinding) }}</p>
</div>
} @else {
}
<!-- Concluded suspicious but not yet submitted to FIU-IND -->
@if (s.status === 'awaiting-filing') {
<div class="str-filing" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>
<fa-icon icon="exclamation-triangle"></fa-icon> Awaiting submission to FIU-IND — due within 7 days
of {{ s.detectedOn }}
</label>
<p class="filing-hint">
Generate the FINnet STR XML, upload it on the FIU-IND portal under
{{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here.
</p>
<div class="filing-row">
<button class="fiu-btn" (click)="exportSingleStrXml(s)">
<fa-icon icon="file-alt"></fa-icon> Generate STR XML
</button>
<input
class="fiu-inp"
type="text"
placeholder="FIU-IND acknowledgement no."
[(ngModel)]="localFileRefs[s.reportRefNo]"
/>
<button class="fiu-btn fiu-btn-xml" (click)="fileLocalStr(s)">Mark STR Filed</button>
</div>
</div>
}
@if (s.status === 'filed' && s.fiuReferenceNo) {
<div class="str-filed-ack">
<fa-icon icon="check-circle"></fa-icon>
<span>
Filed with FIU-IND on <strong>{{ s.filedOn }}</strong> &middot; acknowledgement
<strong class="mono">{{ s.fiuReferenceNo }}</strong>
</span>
</div>
}
@if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) {
<div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>Principal Officer investigation — is the source of funds legitimate?</label>
<div class="fiu-action-group">
<button class="fiu-btn fiu-btn-clean" (click)="concludeLocalStr(s, 'CLEAN')">
Clean — close case
</button>
<button class="fiu-btn fiu-btn-xml" (click)="concludeLocalStr(s, 'SUSPICIOUS')">
Suspicious — file STR with FIU-IND
<button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
<fa-icon icon="search"></fa-icon> Investigate this case
</button>
</div>
}
<!-- Investigation workspace for this case -->
@if (investigatingStr?.reportRefNo === s.reportRefNo) {
<div class="str-investigation" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="inv-head">
<span>Principal Officer investigation — STR-{{ s.reportRefNo }}</span>
<button class="fiu-link-btn fiu-link-btn--muted" (click)="closeLocalStrInvestigation()">
Cancel
</button>
</div>
<!-- Step 1 — call for evidence -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">1</span> Documentary evidence called for</div>
<div class="inv-grid">
<div class="form-row">
<label for="invDocType">Document requested from the customer</label>
<select id="invDocType" class="fiu-inp" [(ngModel)]="evidenceDocType">
<option value="">Select document…</option>
@for (d of evidenceDocTypes; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</div>
<div class="form-row">
<label for="invReceived">Was it produced?</label>
<select id="invReceived" class="fiu-inp" [(ngModel)]="evidenceReceived">
<option value="">Select…</option>
<option value="YES">Yes — complete</option>
<option value="PARTIAL">Partially — incomplete</option>
<option value="NO">No — not produced</option>
</select>
</div>
<div class="form-row">
<label for="invRef">Document / reference number</label>
<input id="invRef" class="fiu-inp" type="text" [(ngModel)]="evidenceReference" />
</div>
</div>
</div>
<!-- Step 2 — customer's account of the funds -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">2</span> Customer’s explanation of the source</div>
<textarea
rows="2"
[(ngModel)]="customerExplanation"
placeholder="e.g. Proceeds of a flat sold in June; sale deed produced and matched to the deposit amount and date."
></textarea>
</div>
<!-- Step 3 — verification checklist -->
<div class="inv-step">
<div class="inv-step-t">
<span class="inv-num">3</span> Verification checklist
<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-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-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 outcome -->
<div
class="verdict"
[class.verdict--clear]="canConcludeLocal && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="canConcludeLocal && derivedConclusion === 'SUSPICIOUS'"
>
@if (!canConcludeLocal) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Complete all three steps to determine the outcome.
@if (!evidenceRecorded) {
<span> Evidence not yet recorded.</span>
}
@if (!allAnswered) {
<span> {{ strChecklist.length - answeredCount }} verification point(s) pending.</span>
}
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Source of funds established.</strong> Case closes as clean — the transaction stays in
the CTR report and no STR is filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Source of funds not established.</strong> {{ failedFindings.length }} point(s)
unsatisfied — an STR will be filed with FIU-IND in addition to the CTR.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!canConcludeLocal" (click)="concludeLocalStr()">
{{
canConcludeLocal
? derivedConclusion === 'SUSPICIOUS'
? 'Conclude — file STR with FIU-IND'
: 'Conclude — close as clean (CTR only)'
: 'Conclude'
}}
</button>
</div>
}
</div>
@@ -871,8 +1098,8 @@
<div class="fiu-form-title">
<h3>FINnet 2.0 Submission Files</h3>
<p>
6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and
STR tabs, so every file in the bundle covers the same transactions.
6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and STR
tabs, so every file in the bundle covers the same transactions.
</p>
</div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
+199
View File
@@ -386,6 +386,78 @@
align-self: end;
}
/* CTR outcome 1: source of funds evidenced at the counter — CTR only. */
.fiu-btn-evidence {
padding: 5px 10px;
font-size: 11px;
font-weight: 600;
color: var(--f-green);
border-color: var(--f-green);
background: var(--f-green-l);
&:hover {
background: var(--f-green);
border-color: var(--f-green);
color: #fff;
}
}
/* Inline evidence capture row inside the TC2 table. */
.evidence-row td {
background: var(--f-green-l) !important;
padding: 0 !important;
}
.evidence-panel {
padding: 12px 16px;
border-left: 3px solid var(--f-green);
}
.evidence-t {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
color: #14532d;
margin-bottom: 10px;
}
.evidence-grid {
display: grid;
grid-template-columns: 2fr 1fr auto;
gap: 12px;
align-items: end;
}
.evidence-note {
margin: 8px 0 0;
font-size: 11px;
color: var(--f-g700);
}
@media (width <= 900px) {
.evidence-grid {
grid-template-columns: 1fr;
}
}
/* CTR outcome 2: evidence unavailable — refer the transaction onward to STR. */
.fiu-btn-refer {
padding: 5px 10px;
font-size: 11px;
font-weight: 600;
color: var(--f-red);
border-color: var(--f-red);
background: var(--f-red-l);
&:hover {
background: var(--f-red);
border-color: var(--f-red);
color: #fff;
}
}
.fiu-link-btn {
display: inline-block;
margin-top: 3px;
@@ -708,6 +780,11 @@
color: var(--f-amber);
}
&.st-awaiting-filing {
background: #ffedd5;
color: #9a3412;
}
&.st-filed {
background: var(--f-green-l);
color: var(--f-green);
@@ -1306,6 +1383,128 @@
}
}
/* Concluded suspicious, still to be submitted on the FINnet portal. */
.str-filing {
background: #fff7ed;
border: 1px solid #fdba74;
border-radius: 8px;
padding: 10px 14px;
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
color: #9a3412;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.filing-hint {
margin: 4px 0 8px;
font-size: 12px;
color: var(--f-g700);
}
.filing-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.fiu-inp {
max-width: 260px;
}
}
.str-filed-ack {
display: flex;
align-items: center;
gap: 8px;
background: var(--f-green-l);
border: 1px solid #86efac;
border-radius: 8px;
padding: 9px 14px;
font-size: 12px;
color: #14532d;
}
/* ── CTR-referred case: Principal Officer investigation workspace ───────── */
.str-investigation {
margin-top: 12px;
border: 1px solid var(--f-purple);
border-radius: 10px;
background: #fdfcff;
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
}
.inv-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font-size: 12px;
font-weight: 700;
color: var(--f-purple);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inv-step {
display: flex;
flex-direction: column;
gap: 8px;
textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--f-g300);
font-family: inherit;
font-size: 13px;
}
}
.inv-step-t {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: var(--f-g900);
}
.inv-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--f-purple);
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.inv-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
}
@media (width <= 900px) {
.inv-grid {
grid-template-columns: 1fr;
}
}
/* Derived verdict banner */
.verdict {
display: flex;
+218 -37
View File
@@ -135,6 +135,54 @@ export class FiuComponent implements OnInit {
/** null = not yet answered, true = genuine, false = not satisfied. */
strFindings: Record<string, boolean | null> = {};
// ── Investigation of a CTR-referred STR case ───────────────────────────────
/** The local STR card currently under investigation, if any. */
investigatingStr: StrTransaction | null = null;
/** Documents the Principal Officer can call for to evidence the source of funds. */
readonly evidenceDocTypes: string[] = [
'Sale deed / agreement for immovable property',
'Salary slips and Form 16',
'Audited financials / ITR of the business',
'Loan sanction or disbursement letter',
'Gift deed with donor PAN and relationship proof',
'Maturity advice of a deposit or insurance policy',
'Sale bill for gold, vehicle or other asset',
'Agricultural income certificate / mandi receipts',
'No document produced by the customer'
];
evidenceDocType = '';
evidenceReceived: 'YES' | 'PARTIAL' | 'NO' | '' = '';
evidenceReference = '';
customerExplanation = '';
/** Opens the investigation panel for a CTR-referred case with a clean slate. */
investigateLocalStr(rec: StrTransaction): void {
this.investigatingStr = rec;
this.selectedStrCase = null;
this.resetChecklist();
this.evidenceDocType = '';
this.evidenceReceived = '';
this.evidenceReference = '';
this.customerExplanation = '';
this.concludeInvestigationNotes = '';
this.concludeSourceOfFundsNotes = '';
}
closeLocalStrInvestigation(): void {
this.investigatingStr = null;
}
/** Documentary evidence is a precondition for clearing a case, not an optional note. */
get evidenceRecorded(): boolean {
return !!this.evidenceDocType && !!this.evidenceReceived;
}
get canConcludeLocal(): boolean {
return this.evidenceRecorded && this.allAnswered && !!this.customerExplanation.trim();
}
private resetChecklist(): void {
this.strFindings = {};
for (const item of this.strChecklist) {
@@ -202,14 +250,51 @@ export class FiuComponent implements OnInit {
}
}
whitelistCtrTxn(txn: CtrTransaction): void {
txn.complianceStatus = 'WHITELISTED';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
this.toast(`Transaction for ${txn.customerName} whitelisted — stays in the CTR report only`);
// ── CTR-side evidence capture ──────────────────────────────────────────────
/** Transaction whose source-of-funds evidence is being recorded, if any. */
evidencingTxn: CtrTransaction | null = null;
ctrEvidenceDocType = '';
ctrEvidenceRef = '';
startCtrEvidence(txn: CtrTransaction): void {
this.evidencingTxn = txn;
this.ctrEvidenceDocType = '';
this.ctrEvidenceRef = '';
}
flagCtrTxnSuspicious(txn: CtrTransaction): void {
txn.complianceStatus = 'SUSPICIOUS';
cancelCtrEvidence(): void {
this.evidencingTxn = null;
}
/**
* Records that the source of funds was evidenced at the counter. This is a
* documentation step, not a suspicion verdict — the transaction is reported in
* the CTR either way; recording evidence simply means no STR referral is needed.
*/
confirmCtrEvidence(): void {
const txn = this.evidencingTxn;
if (!txn) {
return;
}
if (!this.ctrEvidenceDocType) {
this.toast('Select the document that evidences the source of funds');
return;
}
txn.complianceStatus = 'EVIDENCED';
txn.evidenceDocType = this.ctrEvidenceDocType;
txn.evidenceReference = this.ctrEvidenceRef.trim();
txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
this.evidencingTxn = null;
this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`);
}
/**
* Referral to STR. Used when the source of funds could not be evidenced; the
* Principal Officer then investigates and decides whether to file with FIU-IND.
*/
referCtrTxnToStr(txn: CtrTransaction): void {
txn.complianceStatus = 'REFERRED';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
@@ -222,34 +307,112 @@ export class FiuComponent implements OnInit {
riskCategory: 'unusual-pattern',
narrative:
`Cash ${txn.depositOrWithdrawal.toLowerCase()} of ₹${txn.txnAmount.toLocaleString('en-IN')} breached the ` +
`₹${this.bank.cashThreshold.toLocaleString('en-IN')} CTR limit and was flagged as suspicious by the AML Officer. ` +
`Referred to the Principal Officer to establish whether the source of funds is legitimate or linked to terror financing.`,
`₹${this.bank.cashThreshold.toLocaleString('en-IN')} CTR limit and genuine evidence of the source of funds ` +
`could not be established at the branch. Referred to the Principal Officer for investigation.`,
status: 'po-review',
poFinding: undefined
};
this.strTxns.unshift(newStrCase);
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
this.toast(`STR-${newRef} opened for ${txn.customerName} — awaiting Principal Officer investigation`);
this.toast(`${txn.customerName} referred to STR-${newRef} — awaiting Principal Officer investigation`);
}
/** Principal Officer closes an STR case as legitimate, or confirms it and files an STR. */
concludeLocalStr(rec: StrTransaction, finding: PoFinding): void {
/**
* Concludes a CTR-referred case. The verdict follows the evidence and the
* verification checklist rather than a free choice, and the full trail is written
* into the case remarks so the filing carries its own justification.
*/
concludeLocalStr(): void {
const rec = this.investigatingStr;
if (!rec) {
return;
}
if (!this.evidenceRecorded) {
this.toast('Record which document was called for and whether it was produced');
return;
}
if (!this.customerExplanation.trim()) {
this.toast('Record the explanation the customer gave for the source of funds');
return;
}
if (!this.allAnswered) {
this.toast(`Answer all verification points — ${this.strChecklist.length - this.answeredCount} still pending`);
return;
}
const finding: PoFinding = this.derivedConclusion === 'SUSPICIOUS' ? 'SUSPICIOUS' : 'CLEAN';
const failed = this.failedFindings;
const evidenceLine =
`Document called for: ${this.evidenceDocType}. Produced: ${this.evidenceReceived}` +
(this.evidenceReference.trim() ? ` (ref ${this.evidenceReference.trim()})` : '') +
'.';
const explanationLine = `Customer's explanation: ${this.customerExplanation.trim()}`;
rec.poFinding = finding;
if (finding === 'CLEAN') {
rec.status = 'rejected';
rec.poRemarks =
'Source-of-funds documentation reviewed and verified by the Principal Officer. No link to terror financing or ' +
'any other predicate offence. Case closed — no STR filed with FIU-IND.';
this.toast(`STR-${rec.reportRefNo} closed as clean — no STR filed`);
`${evidenceLine} ${explanationLine} All ${this.strChecklist.length} verification points found genuine, ` +
`so the source of funds is established. Case closed — the transaction remains in the CTR report and no STR ` +
`is filed with FIU-IND.`;
// The originating CTR row must stop advertising a live STR once cleared.
const origin = this.ctrTxns.find((t) => t.txnId === rec.txnId);
if (origin) {
origin.complianceStatus = 'EVIDENCED';
origin.evidenceDocType = this.evidenceDocType;
origin.evidenceReference = this.evidenceReference.trim();
origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
}
this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`);
} else {
rec.status = 'filed';
// Concluding suspicious does not file anything — the STR still has to be
// submitted on the FINnet portal and its acknowledgement recorded.
rec.status = 'awaiting-filing';
rec.poRemarks =
'Principal Officer investigation could not establish a legitimate source of funds. STR filed with FIU-IND under ' +
'PMLA s.12 within the 7-day window; account placed under enhanced monitoring and reported to the Nodal Officer.';
this.toast(`STR-${rec.reportRefNo} confirmed suspicious — filed with FIU-IND`);
`${evidenceLine} ${explanationLine} ${failed.length} of ${this.strChecklist.length} verification points not ` +
`satisfied: ${failed.map((f) => f.label).join('; ')}. Source of funds not established — STR to be filed with ` +
`FIU-IND under PMLA s.12 within 7 days and the account placed under enhanced monitoring.`;
this.toast(`STR-${rec.reportRefNo} concluded suspicious — now awaiting submission to FIU-IND`);
}
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
this.investigatingStr = null;
}
/** Acknowledgement number captured against a case awaiting submission. */
localFileRefs: Record<number, string> = {};
/**
* Records that the STR was actually submitted on the FINnet portal. Only this
* step moves a case to "filed" and counts it in the filed KPI.
*/
fileLocalStr(rec: StrTransaction): void {
const ref = (this.localFileRefs[rec.reportRefNo] || '').trim();
if (!ref) {
this.toast('Enter the FIU-IND acknowledgement number received on the FINnet portal');
return;
}
rec.status = 'filed';
rec.fiuReferenceNo = ref;
rec.filedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
rec.poRemarks = `${rec.poRemarks ?? ''} Submitted to FIU-IND on ${rec.filedOn}; acknowledgement ${ref}.`.trim();
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
this.toast(`STR-${rec.reportRefNo} filed with FIU-IND — acknowledgement ${ref}`);
}
/** Generates the FINnet STR XML for one case, ready to upload to the portal. */
exportSingleStrXml(rec: StrTransaction): void {
const saved = this.strFilterStatus;
const savedRisk = this.strFilterRisk;
// Temporarily narrow the export to this case only, then restore the filters.
this.strFilterStatus = '';
this.strFilterRisk = '';
const only = [rec];
this.writeStrXml(only, `STR-${rec.reportRefNo}-${rec.customerName ?? 'case'}.xml`);
this.strFilterStatus = saved;
this.strFilterRisk = savedRisk;
this.toast(`STR-${rec.reportRefNo} XML generated — upload it on the FINnet portal`);
}
poFindingLabel(finding: PoFinding): string {
@@ -363,12 +526,7 @@ export class FiuComponent implements OnInit {
: `${this.concludeInvestigationNotes.trim()}\n\nAll ${this.strChecklist.length} verification points found genuine.`;
this.amlService
.concludeStrCase(
this.selectedStrCase.id,
this.concludeConclusion,
notes,
this.concludeSourceOfFundsNotes
)
.concludeStrCase(this.selectedStrCase.id, this.concludeConclusion, notes, this.concludeSourceOfFundsNotes)
.subscribe({
next: (updated) => {
this.toast(
@@ -406,16 +564,16 @@ export class FiuComponent implements OnInit {
}
/**
* Where a live AML alert will end up once triaged — drives the "Routes To" column.
* `isCtrRule` distinguishes mechanical CTR-threshold alerts (whitelisting just keeps them
* in the CTR file) from behavioral alerts (whitelisting clears them with no report at all).
* Where a live AML alert stands — drives the "Routes To" column.
* Whitelisting is an STR-side decision, so a CTR alert can only be pending or
* referred onward. Historic WHITELISTED rows are still rendered for the record.
*/
alertRouteLabel(status: string, isCtrRule: boolean = true): string {
switch (status) {
case 'WHITELISTED':
return isCtrRule ? 'CTR only' : 'Cleared';
case 'ESCALATED':
return '→ STR';
return isCtrRule ? 'Referred to STR' : '→ STR';
default:
return 'Pending';
}
@@ -450,9 +608,7 @@ export class FiuComponent implements OnInit {
this.resetStrFilters();
this.highlightedStrRef = match.reportRefNo;
setTimeout(() => {
document
.getElementById(`str-card-${match.reportRefNo}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
document.getElementById(`str-card-${match.reportRefNo}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
@@ -467,7 +623,9 @@ export class FiuComponent implements OnInit {
return this.ctrTxns.reduce((s, t) => s + t.txnAmount, 0);
}
get pendingStr(): number {
return this.strTxns.filter((s) => s.status === 'draft' || s.status === 'po-review').length;
return this.strTxns.filter(
(s) => s.status === 'draft' || s.status === 'po-review' || s.status === 'awaiting-filing'
).length;
}
get filedStr(): number {
return this.strTxns.filter((s) => s.status === 'filed').length;
@@ -496,12 +654,20 @@ export class FiuComponent implements OnInit {
}
get ctrAlerts(): any[] {
const ctrRuleCodes = ['CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L'];
const ctrRuleCodes = [
'CTR_THRESHOLD_10L',
'STRUCTURED_CTR_10L_MONTHLY',
'LARGE_CASH_TXN_25L'
];
return this.amlAlerts.filter((a) => ctrRuleCodes.includes(a.ruleCode));
}
get amlSuspiciousAlerts(): any[] {
const ctrRuleCodes = ['CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L'];
const ctrRuleCodes = [
'CTR_THRESHOLD_10L',
'STRUCTURED_CTR_10L_MONTHLY',
'LARGE_CASH_TXN_25L'
];
return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
}
@@ -544,7 +710,14 @@ export class FiuComponent implements OnInit {
// -------------------- Helpers --------------------
statusLabel(s: StrStatus): string {
return s === 'draft' ? 'Draft' : s === 'po-review' ? 'PO Review' : s === 'filed' ? 'Filed' : 'Rejected';
const map: Record<StrStatus, string> = {
draft: 'Draft',
'po-review': 'PO Review',
'awaiting-filing': 'Awaiting Filing',
filed: 'Filed',
rejected: 'Closed — Clean'
};
return map[s] ?? s;
}
riskLabel(c: StrTransaction['riskCategory']): string {
const map: Record<StrTransaction['riskCategory'], string> = {
@@ -912,6 +1085,12 @@ export class FiuComponent implements OnInit {
this.toast('No suspect cases to export — nothing matches the current filters');
return;
}
this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`);
this.toast(`STR XML exported (${suspects.length} suspects)`);
}
/** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */
private writeStrXml(suspects: StrTransaction[], filename: string): void {
const xml: string[] = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.bank.reportingMonth}">`);
@@ -927,6 +1106,9 @@ export class FiuComponent implements OnInit {
xml.push(` <Detection date="${s.detectedOn}" by="${this.escapeXml(s.flaggedBy)}" />`);
xml.push(` <Narrative>${this.escapeXml(s.narrative)}</Narrative>`);
if (s.poRemarks) xml.push(` <PORemarks>${this.escapeXml(s.poRemarks)}</PORemarks>`);
if (s.fiuReferenceNo) {
xml.push(` <Acknowledgement ref="${this.escapeXml(s.fiuReferenceNo)}" filedOn="${s.filedOn ?? ''}" />`);
}
xml.push(' </Suspect>');
}
xml.push('</STR>');
@@ -934,10 +1116,9 @@ export class FiuComponent implements OnInit {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
this.toast(`STR XML exported (${suspects.length} suspects)`);
}
/** Bundle: the 6 CSVs plus the STR XML — emitted as separate downloads sequentially */
@@ -0,0 +1,638 @@
/**
* 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 { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data';
/**
* Transactions of customers who banked on a Form 97 / Form 98 declaration.
*
* Rule 114B of the Income-tax Rules lists transactions where quoting PAN is
* mandatory. A customer who has no PAN gives a declaration instead:
*
* Form 97 — person with no PAN (income may be taxable).
* Form 98 — person whose only income is agricultural and who has no PAN.
*
* The bank must be able to produce, for any period, every transaction put
* through on the strength of such a declaration. That is what this report is.
*/
export type FormType = 'FORM_60' | 'FORM_61';
/** Screen and workbook labels — this bank numbers the declarations 97 and 98. */
export const FORM_LABEL: Record<FormType, string> = {
FORM_60: 'Form 97',
FORM_61: 'Form 98'
};
export interface FormDeclarant {
/** Declaration acknowledgement number, as issued by the branch. */
ackNo: string;
formType: FormType;
name: string;
fatherName: string;
dateOfBirth: string;
address: string;
branchCode: string;
accountNo: string;
/** Why the customer has no PAN — printed on the declaration. */
reasonForNoPan: string;
estimatedIncome: number;
declaredOn: string;
aadhaarLast4: string;
}
export type TxnMode = 'CASH' | 'TRANSFER' | 'CLEARING' | 'NEFT' | 'RTGS' | 'UPI' | 'ATM';
export interface FormDeclarantTxn {
txnId: string;
ackNo: string;
date: string;
branchCode: string;
accountNo: string;
mode: TxnMode;
drCr: 'DR' | 'CR';
amount: number;
/** What the transaction was — a Rule 114B category or ordinary activity. */
nature: string;
/** Coarse bucket the nature belongs to, e.g. Cash, Clearing, Loan. */
group: string;
/** True when the nature is one of the categories listed in Rule 114B. */
rule114B: boolean;
particulars: string;
voucherNo: string;
}
/** Deterministic linear congruential generator — same figures on every load. */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function pick<T>(rand: () => number, list: T[]): T {
return list[Math.floor(rand() * list.length)];
}
function isoDate(d: Date): string {
const y = d.getUTCFullYear();
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
const day = `${d.getUTCDate()}`.padStart(2, '0');
return `${y}-${m}-${day}`;
}
/**
* Thresholds that make a row worth a second look.
*
* `RULE_114B_CASH` is the cash-deposit limit above which a declaration is
* required at all; `CTR` is the aggregate cash figure that puts the customer
* into the monthly Cash Transaction Report.
*/
export const FORM_THRESHOLD = {
RULE_114B_CASH: 50_000,
CTR: 1_000_000
};
const FIRST_NAMES = [
'Ramesh',
'Sunita',
'Vitthal',
'Kamala',
'Ganpat',
'Shobha',
'Dnyaneshwar',
'Mangala',
'Bhaskar',
'Anita',
'Tukaram',
'Sarika',
'Nivrutti',
'Rekha',
'Popat',
'Chhaya',
'Ashok',
'Vaishali',
'Bapu',
'Nanda',
'Sitaram',
'Jayshree',
'Madhukar',
'Pushpa',
'Eknath',
'Sulochana',
'Baban',
'Indubai',
'Hanumant',
'Kalpana'
];
const SURNAMES = [
'Jadhav',
'Pawar',
'Shinde',
'Gaikwad',
'More',
'Kadam',
'Bhosale',
'Patil',
'Sawant',
'Chavan',
'Salunkhe',
'Mane',
'Deshmukh',
'Thorat',
'Nikam',
'Wagh',
'Kale',
'Ghadge',
'Bhoir',
'Tandel'
];
const VILLAGES = [
'Wadgaon',
'Kolhewadi',
'Shirgaon',
'Nandgaon',
'Pimpalgaon',
'Bhosari',
'Rajgurunagar',
'Talegaon',
'Karjat',
'Murbad',
'Shahapur',
'Vasind'
];
/** Reasons a Form 97 declarant gives — Form 98 has its own single reason. */
const NO_PAN_REASONS = [
'Applied for PAN, allotment awaited',
'Total income below taxable limit',
'Not required to file return of income',
'First-time account holder, PAN not yet obtained'
];
/**
* Every kind of transaction one of these customers puts through — not only the
* ones that triggered a declaration.
*
* `rule114B` marks the categories listed in Rule 114B, where quoting PAN (and
* therefore the Form 97/98 declaration) is mandatory above the limit. The rest
* are ordinary account activity, which the report still has to show: the point
* is the customer's whole picture, not just the declared slice.
*
* `drCr` is fixed where the category only goes one way; 'EITHER' lets the
* generator choose. `cashOnly` forces counter cash, `nonCash` excludes it.
*/
interface TxnKind {
nature: string;
group: string;
rule114B: boolean;
drCr: 'DR' | 'CR' | 'EITHER';
cashOnly?: boolean;
nonCash?: boolean;
/** Relative frequency — small routine postings outnumber big declared ones. */
weight: number;
/** Typical value band, before the occasional large-value tail. */
min: number;
max: number;
}
const TXN_KINDS: TxnKind[] = [
// ── Rule 114B categories: the transactions the declaration was taken for ──
{
nature: 'Cash deposit exceeding ₹50,000 in a day',
group: 'Cash',
rule114B: true,
drCr: 'CR',
cashOnly: true,
weight: 14,
min: 50_000,
max: 470_000
},
{
nature: 'Cash withdrawal exceeding ₹50,000',
group: 'Cash',
rule114B: true,
drCr: 'DR',
cashOnly: true,
weight: 8,
min: 50_000,
max: 380_000
},
{
nature: 'Opening of deposit account',
group: 'Account',
rule114B: true,
drCr: 'CR',
cashOnly: true,
weight: 3,
min: 5_000,
max: 50_000
},
{
nature: 'Term deposit exceeding ₹50,000',
group: 'Deposit',
rule114B: true,
drCr: 'DR',
weight: 5,
min: 50_000,
max: 900_000
},
{
nature: 'Purchase of demand draft in cash',
group: 'Remittance',
rule114B: true,
drCr: 'DR',
cashOnly: true,
weight: 4,
min: 50_000,
max: 260_000
},
{
nature: 'Sale/purchase of goods or services above ₹2,00,000',
group: 'Trade',
rule114B: true,
drCr: 'EITHER',
weight: 3,
min: 200_000,
max: 850_000
},
// ── Ordinary account activity ────────────────────────────────────────────
{
nature: 'Cash deposit',
group: 'Cash',
rule114B: false,
drCr: 'CR',
cashOnly: true,
weight: 16,
min: 500,
max: 49_900
},
{
nature: 'Cash withdrawal',
group: 'Cash',
rule114B: false,
drCr: 'DR',
cashOnly: true,
weight: 15,
min: 500,
max: 49_900
},
{
nature: 'Cheque deposit — outward clearing',
group: 'Clearing',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 7,
min: 2_000,
max: 180_000
},
{
nature: 'Cheque paid — inward clearing',
group: 'Clearing',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 6,
min: 2_000,
max: 160_000
},
{
nature: 'NEFT inward credit',
group: 'Remittance',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 6,
min: 1_000,
max: 220_000
},
{
nature: 'NEFT outward remittance',
group: 'Remittance',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 5,
min: 1_000,
max: 200_000
},
{
nature: 'RTGS inward credit',
group: 'Remittance',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 2,
min: 200_000,
max: 1_200_000
},
{
nature: 'UPI / mobile banking transfer',
group: 'Digital',
rule114B: false,
drCr: 'EITHER',
nonCash: true,
weight: 10,
min: 100,
max: 24_000
},
{
nature: 'ATM cash withdrawal',
group: 'Digital',
rule114B: false,
drCr: 'DR',
cashOnly: true,
weight: 9,
min: 500,
max: 20_000
},
{
nature: 'Transfer between own accounts',
group: 'Transfer',
rule114B: false,
drCr: 'EITHER',
nonCash: true,
weight: 5,
min: 1_000,
max: 120_000
},
{
nature: 'Loan instalment (EMI) recovery',
group: 'Loan',
rule114B: false,
drCr: 'DR',
weight: 7,
min: 2_500,
max: 42_000
},
{
nature: 'Loan disbursement credited',
group: 'Loan',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 2,
min: 50_000,
max: 700_000
},
{
nature: 'Interest credited on deposit',
group: 'Interest',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 6,
min: 120,
max: 14_000
},
{
nature: 'Interest debited on loan',
group: 'Interest',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 4,
min: 200,
max: 11_000
},
{
nature: 'Term deposit matured and credited',
group: 'Deposit',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 3,
min: 25_000,
max: 620_000
},
{
nature: 'Recurring deposit instalment',
group: 'Deposit',
rule114B: false,
drCr: 'DR',
weight: 5,
min: 500,
max: 12_000
},
{
nature: 'Service charges / commission recovered',
group: 'Charges',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 6,
min: 25,
max: 2_400
},
{
nature: 'Cheque return charges',
group: 'Charges',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 2,
min: 100,
max: 900
},
{
nature: 'Locker rent recovered',
group: 'Charges',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 1,
min: 800,
max: 4_500
},
{
nature: 'Dividend credited on shares',
group: 'Shares',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 2,
min: 200,
max: 9_000
},
{
nature: 'Share capital subscription',
group: 'Shares',
rule114B: false,
drCr: 'DR',
weight: 2,
min: 500,
max: 25_000
}
];
/** Flattened by weight so a single draw picks a category at the right rate. */
const KIND_POOL: TxnKind[] = TXN_KINDS.flatMap((k) => Array<TxnKind>(k.weight).fill(k));
/** The distinct groups, for the report's category filter. */
export const TXN_GROUPS: string[] = [...new Set(TXN_KINDS.map((k) => k.group))].sort();
let declarantCache: FormDeclarant[] | null = null;
let txnCache: FormDeclarantTxn[] | null = null;
/**
* The customers who filed a declaration. Roughly one in five is a Form 98
* (agricultural income only), which matches the mix a semi-urban co-operative
* bank sees — the rural branches carry most of them.
*/
export function formDeclarants(): FormDeclarant[] {
if (declarantCache) {
return declarantCache;
}
const rand = lcg(31071998);
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
const out: FormDeclarant[] = [];
for (let i = 0; i < 148; i++) {
const branch = branches[i % branches.length];
// Form 98 is the agricultural-income declaration, so it clusters in the
// outlying branches rather than being spread evenly.
const rural = branch.code === '0004' || branch.code === '0006';
const formType: FormType = rand() < (rural ? 0.42 : 0.08) ? 'FORM_61' : 'FORM_60';
const first = pick(rand, FIRST_NAMES);
const last = pick(rand, SURNAMES);
const seq = `${i + 1}`.padStart(3, '0');
// Declarations are taken in the run-up to and during the ledger period.
const declOffset = Math.floor(rand() * 150);
const declDate = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
declDate.setUTCDate(declDate.getUTCDate() - 60 + declOffset);
const birthYear = 1955 + Math.floor(rand() * 45);
const birthMonth = 1 + Math.floor(rand() * 12);
const birthDay = 1 + Math.floor(rand() * 28);
out.push({
ackNo: `${branch.code}/F${formType === 'FORM_60' ? '97' : '98'}/${seq}`,
formType,
name: `${first} ${last}`,
fatherName: `${pick(rand, FIRST_NAMES)} ${last}`,
dateOfBirth: `${birthYear}-${`${birthMonth}`.padStart(2, '0')}-${`${birthDay}`.padStart(2, '0')}`,
address: `${pick(rand, VILLAGES)}, Tal. ${pick(rand, VILLAGES)}, Dist. Thane`,
branchCode: branch.code,
accountNo: `${branch.code}${(4100000 + i * 137).toString().padStart(8, '0')}`,
reasonForNoPan:
formType === 'FORM_61' ? 'Income consists only of agricultural income' : pick(rand, NO_PAN_REASONS),
// Form 98 declarants report agricultural income, which the Act exempts, so
// their declared figures sit below the Form 97 band.
estimatedIncome:
formType === 'FORM_61' ? 90_000 + Math.floor(rand() * 160_000) : 140_000 + Math.floor(rand() * 380_000),
declaredOn: isoDate(declDate),
aadhaarLast4: `${1000 + Math.floor(rand() * 9000)}`
});
}
declarantCache = out;
return out;
}
/**
* Every transaction these customers put through inside the ledger period.
*
* A declaration is taken per transaction category, so an active customer shows
* several rows; a customer who only opened an account shows one.
*/
export function formDeclarantTxns(): FormDeclarantTxn[] {
if (txnCache) {
return txnCache;
}
const rand = lcg(19980731);
const start = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
const span = Math.round((end.getTime() - start.getTime()) / 86_400_000);
const out: FormDeclarantTxn[] = [];
let voucher = 1;
for (const d of formDeclarants()) {
// Weighted low — most declarants are occasional users and a handful bank
// every week. Squaring the draw gives that long tail rather than a flat
// spread, while still leaving enough volume for a day-wise filter to show.
const count = 1 + Math.floor(rand() * rand() * 22);
for (let n = 0; n < count; n++) {
const day = new Date(start.getTime());
day.setUTCDate(day.getUTCDate() + Math.floor(rand() * span));
if (day.getUTCDay() === 0) {
day.setUTCDate(day.getUTCDate() + 1); // Sunday — no counter business.
}
const kind = pick(rand, KIND_POOL);
const drCr: 'DR' | 'CR' = kind.drCr === 'EITHER' ? (rand() < 0.5 ? 'DR' : 'CR') : kind.drCr;
// The channel follows the category: cash categories go over the counter,
// clearing goes through the house, and the rest split across the
// electronic rails a co-operative bank offers.
let mode: TxnMode;
if (kind.cashOnly) {
mode = kind.nature.startsWith('ATM') ? 'ATM' : 'CASH';
} else if (kind.nonCash) {
mode =
kind.group === 'Clearing'
? 'CLEARING'
: kind.nature.startsWith('RTGS')
? 'RTGS'
: kind.nature.startsWith('NEFT')
? 'NEFT'
: kind.group === 'Digital'
? 'UPI'
: 'TRANSFER';
} else {
// Categories that can go either way — a term deposit or an EMI is paid
// in cash as often as it is transferred at this kind of bank.
mode = rand() < 0.55 ? 'CASH' : 'TRANSFER';
}
// Inside the category's own band, with a thin large-value tail on the
// Rule 114B rows so the report has CTR-sized entries to flag.
let amount = kind.min + Math.floor(rand() * (kind.max - kind.min));
if (kind.rule114B && kind.cashOnly && rand() < 0.09) {
amount = FORM_THRESHOLD.CTR + Math.floor(rand() * 1_400_000);
}
amount = Math.round(amount / 100) * 100 || 100;
const cashWord = mode === 'CASH' || mode === 'ATM';
out.push({
txnId: `TXN${`${out.length + 1}`.padStart(5, '0')}`,
ackNo: d.ackNo,
date: isoDate(day),
branchCode: d.branchCode,
accountNo: d.accountNo,
mode,
drCr,
amount,
nature: kind.nature,
group: kind.group,
rule114B: kind.rule114B,
particulars:
drCr === 'DR'
? `${cashWord ? 'Cash paid to' : 'Debit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`
: `${cashWord ? 'Cash received from' : 'Credit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`,
voucherNo: `${d.branchCode}/V/${`${voucher++}`.padStart(5, '0')}`
});
}
}
out.sort((a, b) => (a.date === b.date ? a.txnId.localeCompare(b.txnId) : a.date.localeCompare(b.date)));
txnCache = out;
return out;
}
export function declarantByAck(ackNo: string): FormDeclarant | undefined {
return formDeclarants().find((d) => d.ackNo === ackNo);
}
@@ -0,0 +1,370 @@
<!--
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="fd-root">
<!-- HEADER -->
<div class="fd-head">
<div class="fd-head-left">
<fa-icon icon="file-signature" class="fd-head-ic"></fa-icon>
<div>
<h2 class="fd-head-t">Form 97 / 98 Transaction Report</h2>
<div class="fd-head-s">
Every transaction put through on a declaration under Rule 114B, for customers who have no PAN. Form 97 is the
general declaration; Form 98 is for a person whose only income is agricultural.
</div>
</div>
</div>
<button class="fd-btn fd-btn--primary" [disabled]="generating" (click)="exportExcel()">
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
</button>
</div>
<!-- FILTERS -->
<div class="fd-filters">
<div class="fd-filter-row">
<div class="fd-modes">
@for (m of periodModes; track m.key) {
<button class="fd-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
{{ m.label }}
</button>
}
</div>
@switch (periodMode) {
@case ('day') {
<label class="fd-field">
<span>Date</span>
<input type="date" [(ngModel)]="filterDay" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
}
@case ('week') {
<label class="fd-field">
<span>Any date in week</span>
<input type="date" [(ngModel)]="filterWeek" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
<span class="fd-resolved">Week: {{ rangeLabel }}</span>
}
@case ('month') {
<label class="fd-field">
<span>Month</span>
<input type="month" [(ngModel)]="filterMonth" min="2026-04" max="2026-07" />
</label>
}
@default {
<label class="fd-field">
<span>From</span>
<input type="date" [(ngModel)]="filterFrom" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
<label class="fd-field">
<span>To</span>
<input type="date" [(ngModel)]="filterTo" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
}
}
</div>
<div class="fd-filter-row">
<label class="fd-field">
<span>Branch</span>
<select [(ngModel)]="branchCode" [disabled]="!canSelectBranch">
@if (canSelectBranch) {
<option value="">All Branches</option>
}
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
}
</select>
</label>
<label class="fd-field">
<span>Declaration</span>
<select [(ngModel)]="formType">
<option value="">Both</option>
<option value="FORM_60">Form 97 — no PAN</option>
<option value="FORM_61">Form 98 — agricultural income</option>
</select>
</label>
<label class="fd-field">
<span>Transaction type</span>
<select [(ngModel)]="group">
<option value="">All types</option>
@for (g of txnGroups; track g) {
<option [value]="g">{{ g }}</option>
}
</select>
</label>
<label class="fd-field fd-field--grow">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Name, account number or acknowledgement no…" />
</label>
<label class="fd-check">
<input type="checkbox" [(ngModel)]="flaggedOnly" />
Only CTR reportable
</label>
<button class="fd-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
@if (!canSelectBranch) {
<div class="fd-scope-note">
<fa-icon icon="lock"></fa-icon>
Showing {{ branchLabel }} only. Head Office logins can select any branch.
</div>
}
</div>
<!-- SUMMARY -->
<div class="fd-kpis">
<div class="fd-kpi">
<div class="fd-kpi-n">{{ summary.persons }}</div>
<div class="fd-kpi-l">Declarants</div>
<div class="fd-kpi-sub">{{ summary.form60Persons }} on Form 97 · {{ summary.form61Persons }} on Form 98</div>
</div>
<div class="fd-kpi fd-kpi--blue">
<div class="fd-kpi-n">{{ summary.txnCount }}</div>
<div class="fd-kpi-l">Transactions — all types</div>
<div class="fd-kpi-sub">{{ summary.declarationRequired }} in a Rule 114B category</div>
</div>
<div class="fd-kpi fd-kpi--amber">
<div class="fd-kpi-n">{{ summary.cashTotal | number: '1.0-0' }}</div>
<div class="fd-kpi-l">Cash turnover (₹)</div>
<div class="fd-kpi-sub">{{ summary.cashCount }} cash transactions</div>
</div>
<div class="fd-kpi" [class.fd-kpi--red]="summary.ctrReportable > 0">
<div class="fd-kpi-n">{{ summary.ctrReportable }}</div>
<div class="fd-kpi-l">CTR reportable</div>
<div class="fd-kpi-sub">cash at or above ₹10,00,000</div>
</div>
</div>
<!-- CATEGORY MIX — what these customers actually do, beyond the declared rows -->
@if (summary.byGroup.length) {
<div class="fd-mix">
<span class="fd-mix-t">Transaction mix</span>
@for (g of summary.byGroup; track g.group) {
<button
class="fd-mix-item"
[class.active]="group === g.group"
(click)="group = group === g.group ? '' : g.group"
>
{{ g.group }} <strong>{{ g.count }}</strong>
</button>
}
</div>
}
<!-- VIEW SWITCH -->
<div class="fd-views">
<button class="fd-chip" [class.active]="view === 'person'" (click)="view = 'person'">
<fa-icon icon="users"></fa-icon> Person wise
</button>
<button class="fd-chip" [class.active]="view === 'transaction'" (click)="view = 'transaction'">
<fa-icon icon="list"></fa-icon> Transaction wise
</button>
<span class="fd-spacer"></span>
<span class="fd-count">
{{ view === 'person' ? personRows.length + ' declarants' : txnRows.length + ' transactions' }}
</span>
</div>
<!-- PERSON WISE -->
@if (view === 'person') {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th></th>
<th>Acknowledgement</th>
<th>Form</th>
<th>Name of declarant</th>
<th>Account No</th>
<th>Branch</th>
<th class="num">Txns</th>
<th class="num">Rule 114B</th>
<th class="num">Cash turnover</th>
<th class="num">Credit</th>
<th class="num">Debit</th>
<th class="num">Declared income</th>
<th class="num">Cash ÷ income</th>
</tr>
</thead>
<tbody>
@for (p of personRows; track p.declarant.ackNo) {
<tr class="fd-person" [class.open]="expanded === p.declarant.ackNo" (click)="toggle(p.declarant.ackNo)">
<td>
<fa-icon [icon]="expanded === p.declarant.ackNo ? 'caret-down' : 'caret-right'"></fa-icon>
</td>
<td class="mono">{{ p.declarant.ackNo }}</td>
<td>
<span class="fd-form" [class.fd-form--61]="p.declarant.formType === 'FORM_61'">
{{ formLabel[p.declarant.formType] }}
</span>
</td>
<td>
<strong>{{ p.declarant.name }}</strong>
@if (p.ctrCount > 0) {
<span class="fd-tag fd-tag--ctr">{{ p.ctrCount }} CTR</span>
}
</td>
<td class="mono">{{ p.declarant.accountNo }}</td>
<td>{{ p.declarant.branchCode }}</td>
<td class="num">{{ p.txnCount }}</td>
<td class="num">{{ p.declaredCount }}</td>
<td class="num strong">{{ p.cashTotal | number: '1.0-0' }}</td>
<td class="num">{{ p.totalCredit | number: '1.0-0' }}</td>
<td class="num">{{ p.totalDebit | number: '1.0-0' }}</td>
<td class="num">{{ p.declarant.estimatedIncome | number: '1.0-0' }}</td>
<td class="num">
<span [class]="ratioClass(p.incomeRatio)">{{ p.incomeRatio | number: '1.1-1' }}×</span>
</td>
</tr>
@if (expanded === p.declarant.ackNo) {
<tr class="fd-detail">
<td colspan="13">
<div class="fd-decl">
<div>
<label>Father's name</label><span>{{ p.declarant.fatherName }}</span>
</div>
<div>
<label>Date of birth</label><span>{{ p.declarant.dateOfBirth }}</span>
</div>
<div>
<label>Aadhaar</label><span class="mono">XXXX XXXX {{ p.declarant.aadhaarLast4 }}</span>
</div>
<div>
<label>Declared on</label><span>{{ p.declarant.declaredOn }}</span>
</div>
<div class="wide">
<label>Address</label><span>{{ p.declarant.address }}</span>
</div>
<div class="wide">
<label>Reason for no PAN</label><span>{{ p.declarant.reasonForNoPan }}</span>
</div>
</div>
<table class="fd-subtable">
<thead>
<tr>
<th>Date</th>
<th>Txn ID</th>
<th>Mode</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th>Nature of transaction (Rule 114B)</th>
<th>Voucher</th>
<th>Flag</th>
</tr>
</thead>
<tbody>
@for (t of p.txns; track t.txnId) {
<tr>
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td>
<span class="fd-mode">{{ t.mode }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">{{ t.amount | number: '1.0-0' }}</td>
<td>{{ t.nature }}</td>
<td class="mono">{{ t.voucherNo }}</td>
<td>
@if (t.ctrReportable) {
<span class="fd-tag fd-tag--ctr">CTR</span>
} @else if (t.declarationRequired) {
<span class="fd-tag">Rule 114B</span>
}
</td>
</tr>
}
</tbody>
</table>
</td>
</tr>
}
} @empty {
<tr>
<td colspan="13" class="fd-empty">
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- TRANSACTION WISE -->
@if (view === 'transaction') {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Date</th>
<th>Txn ID</th>
<th>Acknowledgement</th>
<th>Form</th>
<th>Name of declarant</th>
<th>Account No</th>
<th>Branch</th>
<th>Category</th>
<th>Mode</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th>Nature of transaction</th>
<th>Voucher</th>
<th>Flag</th>
</tr>
</thead>
<tbody>
@for (t of txnRows; track t.txnId) {
<tr [class.fd-row--ctr]="t.ctrReportable">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td class="mono">{{ t.ackNo }}</td>
<td>
<span class="fd-form" [class.fd-form--61]="t.formType === 'FORM_61'">{{ formLabel[t.formType] }}</span>
</td>
<td>{{ t.declarantName }}</td>
<td class="mono">{{ t.accountNo }}</td>
<td>{{ t.branchCode }}</td>
<td>
<span class="fd-group">{{ t.group }}</span>
</td>
<td>
<span class="fd-mode">{{ t.mode }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">{{ t.amount | number: '1.0-0' }}</td>
<td>{{ t.nature }}</td>
<td class="mono">{{ t.voucherNo }}</td>
<td>
@if (t.ctrReportable) {
<span class="fd-tag fd-tag--ctr">CTR</span>
} @else if (t.declarationRequired) {
<span class="fd-tag">Rule 114B</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="14" class="fd-empty">
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="fd-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -0,0 +1,608 @@
/**
* 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 {
--f-navy: #1a237e;
--f-blue: #1d4ed8;
--f-blue-l: #eff6ff;
--f-green: #15803d;
--f-green-l: #dcfce7;
--f-amber: #b45309;
--f-amber-l: #fef3c7;
--f-red: #c0392b;
--f-red-l: #fdecea;
--f-g50: #f8fafc;
--f-g100: #f1f5f9;
--f-g200: #e2e8f0;
--f-g300: #cbd5e1;
--f-g500: #64748b;
--f-g700: #334155;
--f-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
background: var(--f-g50);
color: var(--f-g900);
font-size: 14px;
line-height: 1.5;
}
.fd-root {
padding: 18px 22px;
max-width: 1700px;
margin: 0 auto;
}
/* HEADER */
.fd-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.fd-head-left {
display: flex;
align-items: flex-start;
gap: 14px;
}
.fd-head-ic {
font-size: 26px;
color: var(--f-navy);
margin-top: 3px;
}
.fd-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.fd-head-s {
font-size: 12px;
color: var(--f-g500);
max-width: 820px;
}
/* BUTTONS */
.fd-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 15px;
border: 1px solid var(--f-g300);
background: #fff;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--f-g700);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
&:hover:not(:disabled) {
background: var(--f-g50);
border-color: var(--f-g500);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
background: var(--f-green);
border-color: var(--f-green);
color: #fff;
&:hover:not(:disabled) {
background: #12692f;
border-color: #12692f;
color: #fff;
}
}
}
/* FILTERS */
.fd-filters {
background: #fff;
border: 1px solid var(--f-g200);
border-radius: 11px;
padding: 12px 16px;
margin-bottom: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.fd-filter-row {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.fd-modes {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.fd-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--f-g300);
background: #fff;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: var(--f-g700);
cursor: pointer;
font-family: inherit;
&:hover {
border-color: var(--f-navy);
}
&.active {
background: var(--f-navy);
border-color: var(--f-navy);
color: #fff;
}
}
.fd-field {
display: flex;
flex-direction: column;
gap: 3px;
span {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--f-g500);
}
input,
select {
padding: 7px 10px;
border: 1px solid var(--f-g300);
border-radius: 7px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: var(--f-g900);
&:focus {
outline: none;
border-color: var(--f-navy);
}
&:disabled {
background: var(--f-g100);
color: var(--f-g500);
}
}
&--grow {
flex: 1;
min-width: 240px;
input {
width: 100%;
}
}
}
.fd-resolved {
font-size: 12px;
color: var(--f-g500);
padding-bottom: 8px;
}
.fd-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
color: var(--f-g700);
padding-bottom: 8px;
cursor: pointer;
}
.fd-scope-note {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--f-amber);
background: var(--f-amber-l);
border: 1px solid #fcd34d;
border-radius: 7px;
padding: 6px 11px;
}
/* KPIs */
.fd-kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.fd-kpi {
background: #fff;
border: 1px solid var(--f-g200);
border-left: 4px solid var(--f-navy);
border-radius: 11px;
padding: 12px 16px;
&--blue {
border-left-color: var(--f-blue);
}
&--amber {
border-left-color: var(--f-amber);
}
&--red {
border-left-color: var(--f-red);
}
}
.fd-kpi-n {
font-size: 22px;
font-weight: 700;
font-family: 'DM Mono', monospace;
}
.fd-kpi-l {
font-size: 12px;
font-weight: 600;
color: var(--f-g700);
}
.fd-kpi-sub {
font-size: 11px;
color: var(--f-g500);
}
/* VIEW SWITCH */
.fd-views {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.fd-spacer {
flex: 1;
}
.fd-count {
font-size: 12px;
color: var(--f-g500);
font-weight: 600;
}
/* TABLE */
.fd-table-wrap {
background: #fff;
border: 1px solid var(--f-g200);
border-radius: 11px;
overflow-x: auto;
}
.fd-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 9px 10px;
background: var(--f-g100);
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--f-g700);
white-space: nowrap;
border-bottom: 1px solid var(--f-g200);
}
td {
padding: 8px 10px;
border-bottom: 1px solid var(--f-g100);
vertical-align: top;
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
tbody tr:hover:not(.fd-detail) {
background: var(--f-g50);
}
}
.fd-person {
cursor: pointer;
&.open {
background: var(--f-blue-l);
}
}
.fd-row--ctr {
background: var(--f-red-l);
}
.fd-detail {
background: #fcfcfe;
td {
padding: 12px 16px 16px;
}
}
.fd-decl {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px 18px;
margin-bottom: 12px;
padding: 10px 14px;
background: #fff;
border: 1px solid var(--f-g200);
border-radius: 8px;
> div {
display: flex;
flex-direction: column;
}
.wide {
grid-column: span 2;
}
label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--f-g500);
}
span {
font-size: 12.5px;
}
}
.fd-subtable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: #fff;
border: 1px solid var(--f-g200);
border-radius: 8px;
overflow: hidden;
th {
text-align: left;
padding: 7px 10px;
background: var(--f-g50);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--f-g500);
white-space: nowrap;
}
td {
padding: 6px 10px;
border-top: 1px solid var(--f-g100);
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
}
/* Badges */
.fd-form {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
background: var(--f-blue-l);
color: var(--f-blue);
white-space: nowrap;
&--61 {
background: var(--f-green-l);
color: var(--f-green);
}
}
.fd-mode {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
font-size: 10.5px;
font-weight: 700;
background: var(--f-g100);
color: var(--f-g700);
}
.fd-group {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
background: #fff;
border: 1px solid var(--f-g300);
color: var(--f-g700);
white-space: nowrap;
}
/* Category mix — doubles as a one-click filter. */
.fd-mix {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.fd-mix-t {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--f-g500);
margin-right: 2px;
}
.fd-mix-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px;
border: 1px solid var(--f-g200);
background: #fff;
border-radius: 7px;
font-size: 11.5px;
font-weight: 600;
color: var(--f-g700);
cursor: pointer;
font-family: inherit;
strong {
font-family: 'DM Mono', monospace;
color: var(--f-navy);
}
&:hover {
border-color: var(--f-navy);
}
&.active {
background: var(--f-navy);
border-color: var(--f-navy);
color: #fff;
strong {
color: #fff;
}
}
}
.fd-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: var(--f-amber-l);
color: var(--f-amber);
white-space: nowrap;
&--ctr {
background: var(--f-red-l);
color: var(--f-red);
}
}
.fd-ratio {
font-weight: 600;
&--mid {
color: var(--f-amber);
}
&--high {
color: var(--f-red);
font-weight: 700;
}
}
.cr {
color: var(--f-green);
font-weight: 700;
}
.dr {
color: var(--f-red);
font-weight: 700;
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 11.5px;
}
.fd-empty {
padding: 44px;
text-align: center;
color: var(--f-g500);
font-size: 13px;
}
/* TOAST */
.fd-toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--f-navy);
color: #fff;
padding: 12px 22px;
border-radius: 10px;
font-size: 13px;
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
opacity: 0;
pointer-events: none;
transition: all 0.25s;
z-index: 1500;
&.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@media (width <= 1200px) {
.fd-kpis {
grid-template-columns: 1fr 1fr;
}
.fd-decl {
grid-template-columns: 1fr 1fr;
}
}
@@ -0,0 +1,474 @@
/**
* 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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data';
import { FORM_LABEL, FORM_THRESHOLD, FormType, TXN_GROUPS } from './form-declaration-data';
import {
DateRange,
FormPersonRow,
FormReportFilter,
FormReportSummary,
FormTxnRow,
formPersonRows,
formReportSummary,
formTxnRows,
monthRange,
weekRange
} from './form-declaration.compute';
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'person' | 'transaction';
@Component({
selector: 'mifosx-form-declaration-report',
templateUrl: './form-declaration-report.component.html',
styleUrls: ['./form-declaration-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class FormDeclarationReportComponent implements OnInit {
private scope = inject(BranchScopeService);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
readonly formLabel = FORM_LABEL;
readonly threshold = FORM_THRESHOLD;
readonly txnGroups = TXN_GROUPS;
readonly periodModes: { key: PeriodMode; label: string }[] = [
{ key: 'day', label: 'Day wise' },
{ key: 'week', label: 'Week wise' },
{ key: 'month', label: 'Month wise' },
{ key: 'range', label: 'Between dates' }
];
// ── Filter state ───────────────────────────────────────────────────────────
periodMode: PeriodMode = 'month';
filterDay = '2026-07-31';
/** Any date inside the wanted week; resolved to Mon–Sat. */
filterWeek = '2026-07-27';
filterMonth = '2026-07';
filterFrom = GL_PERIOD.fyStart;
filterTo = GL_PERIOD.fyEnd;
branchCode = '';
formType: FormType | '' = '';
group = '';
search = '';
flaggedOnly = false;
view: ViewMode = 'person';
/** Acknowledgement number of the person row currently expanded. */
expanded: string | null = null;
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: any;
ngOnInit(): void {
// A branch login opens on its own book and cannot move off it.
this.branchCode = this.scope.defaultBranchCode;
}
// ── Filter resolution ──────────────────────────────────────────────────────
get range(): DateRange {
if (this.periodMode === 'day') {
return { from: this.filterDay, to: this.filterDay };
}
if (this.periodMode === 'week') {
return weekRange(this.filterWeek);
}
if (this.periodMode === 'month') {
return monthRange(this.filterMonth);
}
return { from: this.filterFrom, to: this.filterTo };
}
get rangeLabel(): string {
const r = this.range;
return r.from === r.to ? r.from : `${r.from} to ${r.to}`;
}
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get branchLabel(): string {
if (!this.canSelectBranch) {
return this.scope.officeName;
}
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
}
/**
* Applied at the data boundary rather than only in the UI, so a branch login
* cannot widen its own scope by tampering with component state.
*/
private get effectiveBranch(): string {
return this.scope.enforce(this.branchCode);
}
private get filter(): FormReportFilter {
return {
range: this.range,
branchCode: this.effectiveBranch,
formType: this.formType,
group: this.group,
search: this.search,
flaggedOnly: this.flaggedOnly
};
}
resetFilters(): void {
this.periodMode = 'month';
this.filterDay = '2026-07-31';
this.filterWeek = '2026-07-27';
this.filterMonth = '2026-07';
this.filterFrom = GL_PERIOD.fyStart;
this.filterTo = GL_PERIOD.fyEnd;
this.formType = '';
this.group = '';
this.search = '';
this.flaggedOnly = false;
this.expanded = null;
// Reset returns a branch login to its own branch, never to "all".
this.branchCode = this.scope.defaultBranchCode;
}
// ── Data ───────────────────────────────────────────────────────────────────
get summary(): FormReportSummary {
return formReportSummary(this.filter);
}
get personRows(): FormPersonRow[] {
return formPersonRows(this.filter);
}
get txnRows(): FormTxnRow[] {
return formTxnRows(this.filter);
}
toggle(ackNo: string): void {
this.expanded = this.expanded === ackNo ? null : ackNo;
}
/** Cash turnover far above declared income is the point of the report. */
ratioClass(ratio: number): string {
if (ratio >= 5) {
return 'fd-ratio fd-ratio--high';
}
if (ratio >= 2) {
return 'fd-ratio fd-ratio--mid';
}
return 'fd-ratio';
}
// ── Excel ──────────────────────────────────────────────────────────────────
async exportExcel(): Promise<void> {
this.generating = true;
try {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBSNexora';
wb.created = new Date();
this.writeSummarySheet(wb);
this.writePersonSheet(wb);
this.writeTransactionSheet(wb);
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Form-97-98-Transaction-Report-${this.range.from}-to-${this.range.to}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.toast(`Exported ${this.summary.txnCount} transactions for ${this.rangeLabel}`);
} catch {
this.toast('Could not generate the workbook — please retry');
} finally {
this.generating = false;
}
}
/** Title band + filter caption, repeated on every sheet so pages stand alone. */
private banner(sheet: ExcelJS.Worksheet, title: string, cols: number): void {
const t = sheet.addRow([title]);
sheet.mergeCells(t.number, 1, t.number, cols);
t.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
t.alignment = { horizontal: 'center', vertical: 'middle' };
t.height = 24;
t.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1A237E' } };
const formCaption = this.formType ? FORM_LABEL[this.formType] : 'Form 97 and Form 98';
const meta = sheet.addRow([
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · Declaration: ${formCaption}` +
` · Category: ${this.group || 'All transaction types'}`
]);
sheet.mergeCells(meta.number, 1, meta.number, cols);
meta.font = { italic: true, size: 10 };
sheet.addRow([]);
}
private headerRow(sheet: ExcelJS.Worksheet, headers: string[]): void {
const head = sheet.addRow(headers);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => {
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
c.border = { bottom: { style: 'thin' } };
});
}
private writeSummarySheet(wb: ExcelJS.Workbook): void {
const s = this.summary;
const sheet = wb.addWorksheet('Summary');
sheet.columns = [
{ width: 52 },
{ width: 24 }];
this.banner(sheet, 'Form 97 / 98 Transaction Report — Summary', 2);
this.headerRow(sheet, [
'Particulars',
'Value'
]);
const money = new Set([
'Total cash turnover',
'Total credits',
'Total debits'
]);
const lines: [string, string | number][] = [
[
'Declarants transacting in the period',
s.persons
],
[
' of which Form 97 (no PAN)',
s.form60Persons
],
[
' of which Form 98 (agricultural income only)',
s.form61Persons
],
[
'Total transactions — all types',
s.txnCount
],
[
' of which cash (counter and ATM)',
s.cashCount
],
[
' of which in a Rule 114B category',
s.declarationRequired
],
[
'Total cash turnover',
s.cashTotal
],
[
'Total credits',
s.totalCredit
],
[
'Total debits',
s.totalDebit
],
[
`Cash transactions at or above ₹${FORM_THRESHOLD.CTR.toLocaleString('en-IN')} (CTR reportable)`,
s.ctrReportable
]
];
for (const [
label,
value
] of lines) {
const row = sheet.addRow([
label,
value
]);
if (money.has(label)) {
row.getCell(2).numFmt = '#,##0.00';
}
}
// Category mix — what the customer group actually does, beyond the
// declared transactions.
sheet.addRow([]);
this.headerRow(sheet, [
'Transaction category',
'Count'
]);
for (const g of s.byGroup) {
sheet.addRow([
g.group,
g.count
]);
}
sheet.views = [{ state: 'frozen', ySplit: 4 }];
}
private writePersonSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Person wise');
const widths = [
18,
12,
26,
24,
18,
14,
10,
10,
10,
16,
16,
16,
14,
10,
34
];
sheet.columns = widths.map((w) => ({ width: w }));
this.banner(sheet, 'Form 97 / 98 Transaction Report — Person wise', widths.length);
this.headerRow(sheet, [
'Acknowledgement',
'Form',
'Name of declarant',
"Father's name",
'Account No',
'Branch',
'Total txns',
'Cash txns',
'Rule 114B txns',
'Cash turnover',
'Total credit',
'Total debit',
'Declared income',
'CTR rows',
'Categories transacted'
]);
for (const p of this.personRows) {
const row = sheet.addRow([
p.declarant.ackNo,
FORM_LABEL[p.declarant.formType],
p.declarant.name,
p.declarant.fatherName,
p.declarant.accountNo,
glBranchName(p.declarant.branchCode),
p.txnCount,
p.cashCount,
p.declaredCount,
p.cashTotal,
p.totalCredit,
p.totalDebit,
p.declarant.estimatedIncome,
p.ctrCount,
p.groups.join(', ')
]);
for (const c of [
10,
11,
12,
13
]) {
row.getCell(c).numFmt = '#,##0.00';
}
}
sheet.views = [{ state: 'frozen', ySplit: 4 }];
}
private writeTransactionSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Transactions');
const widths = [
12,
12,
18,
12,
26,
18,
14,
12,
10,
6,
16,
46,
16,
12
];
sheet.columns = widths.map((w) => ({ width: w }));
this.banner(sheet, 'Form 97 / 98 Transaction Report — Transaction wise', widths.length);
this.headerRow(sheet, [
'Date',
'Txn ID',
'Acknowledgement',
'Form',
'Name of declarant',
'Account No',
'Branch',
'Category',
'Mode',
'Dr/Cr',
'Amount',
'Nature of transaction',
'Voucher No',
'Flag'
]);
for (const t of this.txnRows) {
const row = sheet.addRow([
t.date,
t.txnId,
t.ackNo,
FORM_LABEL[t.formType],
t.declarantName,
t.accountNo,
glBranchName(t.branchCode),
t.group,
t.mode,
t.drCr,
t.amount,
t.nature,
t.voucherNo,
t.ctrReportable ? 'CTR' : t.declarationRequired ? 'Rule 114B' : ''
]);
row.getCell(11).numFmt = '#,##0.00';
if (t.ctrReportable) {
row.getCell(14).font = { bold: true, color: { argb: 'FFC0392B' } };
}
}
sheet.views = [{ state: 'frozen', ySplit: 4 }];
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
@@ -0,0 +1,296 @@
/**
* 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 { GL_PERIOD } from '../gl-reports/gl-reports-data';
import {
FORM_THRESHOLD,
FormDeclarant,
FormDeclarantTxn,
FormType,
formDeclarants,
formDeclarantTxns
} from './form-declaration-data';
export interface DateRange {
from: string;
to: string;
}
export interface FormReportFilter {
range: DateRange;
/** '' means every branch. */
branchCode: string;
/** '' means both declaration types. */
formType: FormType | '';
/** '' means every transaction category (Cash, Clearing, Loan, …). */
group: string;
/** Free text over name, account number and acknowledgement number. */
search: string;
/** Show only rows a compliance officer would need to look at. */
flaggedOnly: boolean;
}
/** One transaction, joined to the declarant who filed for it. */
export interface FormTxnRow extends FormDeclarantTxn {
declarantName: string;
formType: FormType;
fatherName: string;
declaredOn: string;
reasonForNoPan: string;
estimatedIncome: number;
/** Counter or ATM cash, as opposed to a book or electronic entry. */
isCash: boolean;
/**
* The transaction is one of the categories in Rule 114B, so PAN — and in its
* absence the Form 97/98 declaration — was mandatory for it.
*/
declarationRequired: boolean;
/** Cash at or above the CTR limit — also lands in the monthly CTR. */
ctrReportable: boolean;
}
/** A declarant with their totals for the period. */
export interface FormPersonRow {
declarant: FormDeclarant;
txns: FormTxnRow[];
txnCount: number;
cashCount: number;
totalCredit: number;
totalDebit: number;
cashTotal: number;
/** Largest single transaction, used to sort the riskiest to the top. */
maxAmount: number;
/** How many of the period's transactions needed the declaration. */
declaredCount: number;
ctrCount: number;
/** Distinct categories the customer transacted in during the period. */
groups: string[];
/**
* Aggregate cash for the period against the income the customer declared.
* A ratio well above 1 is the classic "turnover inconsistent with declared
* income" flag, which is why the declaration is collected in the first place.
*/
incomeRatio: number;
}
export interface FormReportSummary {
persons: number;
form60Persons: number;
form61Persons: number;
txnCount: number;
cashTotal: number;
cashCount: number;
totalCredit: number;
totalDebit: number;
/** Transactions falling in a Rule 114B category. */
declarationRequired: number;
ctrReportable: number;
/** Transaction count by category, largest first — drives the mix panel. */
byGroup: { group: string; count: number; amount: number }[];
}
function clamp(range: DateRange): DateRange {
const from = range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from;
const to = range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to;
// A backwards range would silently return nothing; collapse it to a single day.
return from > to ? { from: to, to: to } : { from, to };
}
/** Monday-to-Saturday banking week containing the given date. */
export function weekRange(anchor: string): DateRange {
const d = new Date(`${anchor}T00:00:00Z`);
const dow = d.getUTCDay(); // 0 = Sunday
const monday = new Date(d.getTime());
monday.setUTCDate(d.getUTCDate() - (dow === 0 ? 6 : dow - 1));
const saturday = new Date(monday.getTime());
saturday.setUTCDate(monday.getUTCDate() + 5);
const iso = (x: Date) => x.toISOString().slice(0, 10);
return { from: iso(monday), to: iso(saturday) };
}
export function monthRange(month: string): DateRange {
const [
y,
m
] = month.split('-').map(Number);
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
return { from: `${month}-01`, to: `${month}-${`${last}`.padStart(2, '0')}` };
}
/**
* Transactions matching the filter, each joined to its declarant.
*
* Memoised on the filter signature: the getters that feed the template are read
* many times per change-detection pass, and re-scanning several hundred rows on
* every read is what made the GL reports stall.
*/
let rowCacheKey = '';
let rowCache: FormTxnRow[] = [];
export function formTxnRows(filter: FormReportFilter): FormTxnRow[] {
const range = clamp(filter.range);
const key = [
range.from,
range.to,
filter.branchCode,
filter.formType,
filter.group,
filter.search.trim().toLowerCase(),
filter.flaggedOnly ? '1' : '0'
].join('|');
if (key === rowCacheKey) {
return rowCache;
}
const byAck = new Map(
formDeclarants().map((d) => [
d.ackNo,
d
])
);
const needle = filter.search.trim().toLowerCase();
const rows: FormTxnRow[] = [];
for (const t of formDeclarantTxns()) {
if (t.date < range.from || t.date > range.to) {
continue;
}
if (filter.branchCode && t.branchCode !== filter.branchCode) {
continue;
}
if (filter.group && t.group !== filter.group) {
continue;
}
const d = byAck.get(t.ackNo);
if (!d) {
continue;
}
if (filter.formType && d.formType !== filter.formType) {
continue;
}
if (
needle &&
!d.name.toLowerCase().includes(needle) &&
!t.accountNo.toLowerCase().includes(needle) &&
!d.ackNo.toLowerCase().includes(needle)
) {
continue;
}
// ATM dispensing is cash in the customer's hands, so it counts towards the
// cash view even though it never crossed the counter.
const isCash = t.mode === 'CASH' || t.mode === 'ATM';
const ctrReportable = isCash && t.amount >= FORM_THRESHOLD.CTR;
if (filter.flaggedOnly && !ctrReportable) {
continue;
}
rows.push({
...t,
declarantName: d.name,
formType: d.formType,
fatherName: d.fatherName,
declaredOn: d.declaredOn,
reasonForNoPan: d.reasonForNoPan,
estimatedIncome: d.estimatedIncome,
isCash,
declarationRequired: t.rule114B,
ctrReportable
});
}
rowCacheKey = key;
rowCache = rows;
return rows;
}
/** The same rows grouped by declarant, heaviest customer first. */
export function formPersonRows(filter: FormReportFilter): FormPersonRow[] {
const byAck = new Map(
formDeclarants().map((d) => [
d.ackNo,
d
])
);
const grouped = new Map<string, FormTxnRow[]>();
for (const r of formTxnRows(filter)) {
const list = grouped.get(r.ackNo);
if (list) {
list.push(r);
} else {
grouped.set(r.ackNo, [r]);
}
}
const out: FormPersonRow[] = [];
for (const [
ackNo,
txns
] of grouped) {
const declarant = byAck.get(ackNo);
if (!declarant) {
continue;
}
const cash = txns.filter((t) => t.isCash);
const cashTotal = cash.reduce((s, t) => s + t.amount, 0);
out.push({
declarant,
txns,
txnCount: txns.length,
cashCount: cash.length,
totalCredit: txns.filter((t) => t.drCr === 'CR').reduce((s, t) => s + t.amount, 0),
totalDebit: txns.filter((t) => t.drCr === 'DR').reduce((s, t) => s + t.amount, 0),
cashTotal,
maxAmount: txns.reduce((m, t) => Math.max(m, t.amount), 0),
declaredCount: txns.filter((t) => t.declarationRequired).length,
ctrCount: txns.filter((t) => t.ctrReportable).length,
groups: [...new Set(txns.map((t) => t.group))].sort(),
incomeRatio: declarant.estimatedIncome > 0 ? cashTotal / declarant.estimatedIncome : 0
});
}
out.sort((a, b) => b.cashTotal - a.cashTotal);
return out;
}
export function formReportSummary(filter: FormReportFilter): FormReportSummary {
const rows = formTxnRows(filter);
const acks = new Set(rows.map((r) => r.ackNo));
const form61 = new Set(rows.filter((r) => r.formType === 'FORM_61').map((r) => r.ackNo));
const form60 = new Set(rows.filter((r) => r.formType === 'FORM_60').map((r) => r.ackNo));
const groupMap = new Map<string, { group: string; count: number; amount: number }>();
for (const r of rows) {
const g = groupMap.get(r.group);
if (g) {
g.count++;
g.amount += r.amount;
} else {
groupMap.set(r.group, { group: r.group, count: 1, amount: r.amount });
}
}
const cash = rows.filter((r) => r.isCash);
return {
persons: acks.size,
form60Persons: form60.size,
form61Persons: form61.size,
txnCount: rows.length,
cashTotal: cash.reduce((s, r) => s + r.amount, 0),
cashCount: cash.length,
totalCredit: rows.filter((r) => r.drCr === 'CR').reduce((s, r) => s + r.amount, 0),
totalDebit: rows.filter((r) => r.drCr === 'DR').reduce((s, r) => s + r.amount, 0),
declarationRequired: rows.filter((r) => r.declarationRequired).length,
ctrReportable: rows.filter((r) => r.ctrReportable).length,
byGroup: [...groupMap.values()].sort((a, b) => b.count - a.count)
};
}
+245 -18
View File
@@ -114,18 +114,84 @@ export const GL_ACCOUNTS: GlAccount[] = [
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420105', name: 'Depreciation on Bank Property', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '430101', name: 'Printing & Stationery', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430102', name: 'Postage, Telegram & Telephone', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430103', name: 'Advertisement & Publicity', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430104', name: 'Repairs & Maintenance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430105', name: 'Travelling & Conveyance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430106', name: 'Computer & Software Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430107', name: 'Legal & Professional Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430108', name: 'Insurance (DICGC & Other)', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430109', name: 'Bank Charges & Commission Paid', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430110', name: 'AGM & Meeting Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{
code: '430101',
name: 'Printing & Stationery',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430102',
name: 'Postage, Telegram & Telephone',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430103',
name: 'Advertisement & Publicity',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430104',
name: 'Repairs & Maintenance',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430105',
name: 'Travelling & Conveyance',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430106',
name: 'Computer & Software Expenses',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430107',
name: 'Legal & Professional Charges',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430108',
name: 'Insurance (DICGC & Other)',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430109',
name: 'Bank Charges & Commission Paid',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430110',
name: 'AGM & Meeting Expenses',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{ code: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430112', name: 'Other Miscellaneous Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{
code: '430112',
name: 'Other Miscellaneous Expenses',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
@@ -261,8 +327,20 @@ const VOUCHER_TEMPLATES: {
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 },
{ debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 },
{ debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 },
{ debit: '410101', credit: '230101', narration: 'Interest provided on savings deposits', scale: 128_000, frequency: 0.8 },
{ debit: '410102', credit: '230101', narration: 'Interest provided on term deposits', scale: 265_000, frequency: 0.9 },
{
debit: '410101',
credit: '230101',
narration: 'Interest provided on savings deposits',
scale: 128_000,
frequency: 0.8
},
{
debit: '410102',
credit: '230101',
narration: 'Interest provided on term deposits',
scale: 265_000,
frequency: 0.9
},
{ debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 },
{ debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
@@ -275,14 +353,32 @@ const VOUCHER_TEMPLATES: {
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 },
{ debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 },
{ debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 },
{ debit: '110102', credit: '110101', narration: 'Cash remitted to RBI currency chest', scale: 1_800_000, frequency: 0.2 },
{ debit: '110101', credit: '110103', narration: 'Funds drawn from correspondent bank', scale: 1_400_000, frequency: 0.2 }
{
debit: '110102',
credit: '110101',
narration: 'Cash remitted to RBI currency chest',
scale: 1_800_000,
frequency: 0.2
},
{
debit: '110101',
credit: '110103',
narration: 'Funds drawn from correspondent bank',
scale: 1_400_000,
frequency: 0.2
}
];
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 },
{ debit: '120102', credit: '110102', narration: 'Purchase of State Govt securities', scale: 1_900_000, frequency: 0.12 },
{
debit: '120102',
credit: '110102',
narration: 'Purchase of State Govt securities',
scale: 1_900_000,
frequency: 0.12
},
{ debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 },
{ debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
@@ -342,7 +438,10 @@ export function glPostings(): GlPosting[] {
let openingDebit = 0;
let openingCredit = 0;
for (const [code, amount] of Object.entries(OPENING_BALANCES)) {
for (const [
code,
amount
] of Object.entries(OPENING_BALANCES)) {
if (!amount) {
continue;
}
@@ -425,7 +524,10 @@ export function glPostings(): GlPosting[] {
for (const branch of GL_BRANCHES) {
// Head Office posts fewer counter transactions than the branches.
const weight = branch.code === 'HO' ? 0.45 : 1;
const templates = branch.code === 'HO' ? [...VOUCHER_TEMPLATES, ...HO_TEMPLATES] : VOUCHER_TEMPLATES;
const templates = branch.code === 'HO' ? [
...VOUCHER_TEMPLATES,
...HO_TEMPLATES
] : VOUCHER_TEMPLATES;
for (const tpl of templates) {
if (rand() > tpl.frequency * weight) {
@@ -487,6 +589,131 @@ export function glPostings(): GlPosting[] {
return out;
}
/**
* Customer sub-ledger.
*
* The GL is account-level, so a client-wise report needs a customer dimension on top.
* Each customer holds a share of one GL head at one branch; balances are not stored,
* they are derived at query time as `share x that head's GL balance`. That way the
* client-wise report always reconciles to the General Ledger exactly, whatever period
* or branch is selected, instead of drifting as a second source of truth.
*/
export interface GlCustomer {
cif: string;
name: string;
branchCode: string;
/** GL head this relationship sits under. */
accountCode: string;
accountNo: string;
/** Fraction of the branch's balance in that head. Shares per (branch, head) sum to 1. */
share: number;
}
const FIRST_NAMES = [
'Ravi',
'Priya',
'Vikram',
'Nirav',
'Kavita',
'Arjun',
'Lakshmi',
'Sameer',
'Anjali',
'Rohan',
'Meera',
'Karan',
'Divya',
'Sanjay',
'Pooja',
'Amit',
'Sneha',
'Manish',
'Rekha',
'Deepak'
];
const LAST_NAMES = [
'Sharma',
'Patel',
'Reddy',
'Shah',
'Verma',
'Deshmukh',
'Iyer',
'Kapoor',
'Nair',
'Joshi',
'Mehta',
'Gupta',
'Rao',
'Bhatt',
'Chauhan'
];
const FIRMS = [
'Traders',
'Enterprises',
'Agencies',
'& Sons',
'Industries',
'Stores',
'Corporation'
];
/** Customer-facing heads a client relationship can sit under. */
const CUSTOMER_HEADS = [
'220101',
'220102',
'220103',
'220104',
'130101',
'130102',
'130103',
'130104',
'130105',
'130106'
];
let cachedCustomers: GlCustomer[] | null = null;
export function glCustomers(): GlCustomer[] {
if (cachedCustomers) {
return cachedCustomers;
}
const rand = lcg(76543210);
const out: GlCustomer[] = [];
let seq = 1;
for (const branch of GL_BRANCHES) {
for (const head of CUSTOMER_HEADS) {
// Deposits are held by many customers; advances by fewer, larger ones.
const isAdvance = head.startsWith('13');
const count = isAdvance ? 2 + Math.floor(rand() * 3) : 3 + Math.floor(rand() * 4);
const weights: number[] = [];
for (let i = 0; i < count; i++) {
weights.push(0.35 + rand() * 0.65);
}
const weightTotal = weights.reduce((a, b) => a + b, 0);
for (let i = 0; i < count; i++) {
const corporate = isAdvance && rand() > 0.55;
const name = corporate
? `${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]} ${FIRMS[Math.floor(rand() * FIRMS.length)]}`
: `${FIRST_NAMES[Math.floor(rand() * FIRST_NAMES.length)]} ${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]}`;
out.push({
cif: `CIF${`${100000 + seq}`}`,
name,
branchCode: branch.code,
accountCode: head,
accountNo: `${branch.code === 'HO' ? '0001' : branch.code}${head}${`${seq}`.padStart(4, '0')}`,
share: weights[i] / weightTotal
});
seq++;
}
}
}
cachedCustomers = out;
return out;
}
export function glAccount(code: string): GlAccount | undefined {
return GL_ACCOUNTS.find((a) => a.code === code);
}
+552 -20
View File
@@ -79,17 +79,33 @@
<div class="glr-filter">
<label>Branch</label>
<select class="glr-inp" [(ngModel)]="branchCode">
<option value="">All Branches (Consolidated)</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
}
</select>
@if (canSelectBranch) {
<select class="glr-inp" [(ngModel)]="branchCode">
<option value="">All Branches (Consolidated)</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
}
</select>
} @else {
<span class="glr-chip" title="Your login is restricted to this branch">
<fa-icon icon="university"></fa-icon> {{ scopeName }}
</span>
}
</div>
<button class="glr-btn" (click)="resetFilters()">Reset</button>
</div>
@if (scopeIsUnresolved) {
<div class="glr-balance glr-balance--bad">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
Your office <strong>{{ scopeName }}</strong> does not match any branch in the ledger, so no figures can be
shown. Ask an administrator to align your office name with a branch.
</span>
</div>
}
<!-- REPORT SELECTOR -->
<div class="glr-tabs">
@for (r of reports; track r.code) {
@@ -193,7 +209,9 @@
@for (r of pnlExpense; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
@@ -229,7 +247,9 @@
@for (r of pnlIncome; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
@@ -281,7 +301,9 @@
@for (r of bsLiabilities; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
@@ -312,7 +334,9 @@
@for (r of bsAssets; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
@@ -331,11 +355,17 @@
@if (activeReport === 'comparative-balance-sheet') {
<div class="glr-section">
<p class="glr-note">
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before
this period began. Change the filter above to compare a different span.
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before this
period began. Change the filter above to compare a different span.
</p>
@for (block of [{ title: 'Capital & Liabilities', rows: compLiabilities }, { title: 'Assets', rows: compAssets }]; track block.title) {
@for (
block of [
{ title: 'Capital & Liabilities', rows: compLiabilities },
{ title: 'Assets', rows: compAssets }
];
track block.title
) {
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">{{ block.title }}</div>
<table class="glr-tbl">
@@ -353,7 +383,9 @@
@for (r of block.rows; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.current | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.previous | number: '1.2-2' }}</td>
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
@@ -379,8 +411,8 @@
@if (activeReport === 'contingent-liabilities') {
<div class="glr-section">
<p class="glr-note">
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong>. These are memoranda accounts
and are deliberately excluded from the Trial Balance and Balance Sheet totals.
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong
>. These are memoranda accounts and are deliberately excluded from the Trial Balance and Balance Sheet totals.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
@@ -419,8 +451,8 @@
@if (activeReport === 'gl-wise-dr-cr') {
<div class="glr-section">
<p class="glr-note">
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every
voucher is double-entry, so the debit and credit totals below must match.
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every voucher
is double-entry, so the debit and credit totals below must match.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
@@ -474,8 +506,8 @@
@if (activeReport === 'misc-expense-ledger') {
<div class="glr-section">
<p class="glr-note">
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}
&mdash; {{ miscRows.length | number }} entries.
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for
{{ branchLabel }} &mdash; {{ miscRows.length | number }} entries.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
@@ -518,6 +550,506 @@
</div>
}
<!-- ══════════════ GL AVERAGE BALANCE ══════════════ -->
@if (activeReport === 'gl-average-balance') {
<div class="glr-section">
<p class="glr-note">
Average of each account's closing balance across every day in
<strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Banks work from average balances rather than a closing
snapshot for interest, CRR/SLR and profitability, because a single day's figure can be dressed up.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
<th class="ta-r">Opening</th>
<th class="ta-r">Closing</th>
<th class="ta-r">Average Daily</th>
<th class="ta-r">Peak</th>
<th class="ta-r">Lowest</th>
<th class="ta-c">Active Days</th>
</tr>
</thead>
<tbody>
@for (r of averageRows; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.group }}</td>
<td class="ta-r mono">{{ r.opening | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.closing | number: '1.2-2' }}</td>
<td class="ta-r mono">
<strong>{{ r.average | number: '1.2-2' }}</strong>
</td>
<td class="ta-r mono">{{ r.peak | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.lowest | number: '1.2-2' }}</td>
<td class="ta-c mono">{{ r.activeDays }}</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="glr-empty">No ledger activity for this period and branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="5">TOTAL ({{ averageRows.length }} accounts)</td>
<td class="ta-r mono">{{ avgTotalAverage | number: '1.2-2' }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- ══════════════ GL MAPPING FOR REPORT ══════════════ -->
@if (activeReport === 'gl-mapping') {
<div class="glr-section">
<p class="glr-note">
Which General Ledger accounts feed each RBI return line. Mappings are created on the OSS 1, OSS 2 and OSS 3
screens using <strong>Add / Remove GLs</strong>; this is the consolidated view of them, with the value each
mapping currently produces from the ledger.
</p>
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Mapped return lines ({{ mappingRows.length }})</div>
<table class="glr-tbl">
<thead>
<tr>
<th>Return</th>
<th>Return line</th>
<th>Mapped GL accounts</th>
<th class="ta-r">Ledger value (&#8377; thousands)</th>
</tr>
</thead>
<tbody>
@for (r of mappingRows; track r.returnCode + r.lineKey) {
<tr>
<td>{{ r.returnLabel }}</td>
<td class="mono">{{ r.lineKey }}</td>
<td>
@for (c of r.codes; track c) {
<span class="glr-gl-chip mono">{{ c }}</span>
}
</td>
<td class="ta-r mono">{{ r.value | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="glr-empty">
No return lines mapped yet. Open OSS 1, 2 or 3 and use “Add / Remove GLs” to pin accounts to a line.
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<div class="glr-tbl-h">Unmapped GL accounts ({{ unmappedAccounts.length }}) — not feeding any return line</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
</tr>
</thead>
<tbody>
@for (a of unmappedAccounts; track a.code) {
<tr>
<td class="mono">{{ a.code }}</td>
<td>{{ a.name }}</td>
<td class="muted">{{ a.group }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="glr-empty">Every GL account feeds at least one return line.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ ACCOUNT WISE BALANCE LIST ══════════════ -->
@if (activeReport === 'account-wise-balance') {
<div class="glr-section">
<p class="glr-note">
Closing balance of every account as at <strong>{{ range.to }}</strong
>, broken out by branch — which branch is actually carrying each balance.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
@for (c of branchColumns; track c) {
<th class="ta-r">{{ branchName(c) }}</th>
}
<th class="ta-r">Total</th>
</tr>
</thead>
<tbody>
@for (r of accountWiseRows; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.group }}</td>
@for (c of branchColumns; track c) {
<td class="ta-r mono">{{ r.byBranch[c] ? (r.byBranch[c] | number: '1.2-2') : '' }}</td>
}
<td class="ta-r mono">
<strong>{{ r.total | number: '1.2-2' }}</strong>
</td>
</tr>
} @empty {
<tr>
<td [attr.colspan]="branchColumns.length + 4" class="glr-empty">
No balances for this period and branch.
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3">TOTAL ({{ accountWiseRows.length }} accounts)</td>
@for (c of branchColumns; track c) {
<td class="ta-r mono">{{ branchColumnTotal(c) | number: '1.2-2' }}</td>
}
<td class="ta-r mono">{{ accountWiseGrandTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- ══════════════ DISTRIBUTION OF BALANCE ══════════════ -->
@if (activeReport === 'distribution-of-balance') {
<div class="glr-section">
<p class="glr-note">
Composition of the book as at <strong>{{ range.to }}</strong> for {{ branchLabel }} — how concentrated each side
of the balance sheet is by account group.
</p>
<div class="glr-two-col">
@for (
block of [
{ title: 'Capital & Liabilities', rows: liabilityDistribution, total: liabilityDistTotal },
{ title: 'Assets', rows: assetDistribution, total: assetDistTotal }
];
track block.title
) {
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">{{ block.title }}</div>
<table class="glr-tbl">
<thead>
<tr>
<th>Group</th>
<th class="ta-c">A/cs</th>
<th class="ta-r">Amount (&#8377;)</th>
<th class="ta-r">Share</th>
<th class="glr-bar-col">Distribution</th>
</tr>
</thead>
<tbody>
@for (r of block.rows; track r.group) {
<tr>
<td>{{ r.group }}</td>
<td class="ta-c mono">{{ r.accounts }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
<td class="ta-r mono">
<strong>{{ r.share | number: '1.2-2' }}%</strong>
</td>
<td>
<div class="glr-bar">
<div class="glr-bar-fill" [style.width.%]="r.share"></div>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="glr-empty">No balances on this side.</td>
</tr>
}
<tr class="total-row">
<td colspan="2">Total</td>
<td class="ta-r mono">{{ block.total | number: '1.2-2' }}</td>
<td class="ta-r mono">100.00%</td>
<td></td>
</tr>
</tbody>
</table>
</div>
}
</div>
</div>
}
<!-- ══════════════ CLIENT WISE BALANCE ══════════════ -->
@if (activeReport === 'client-wise-balance') {
<div class="glr-section">
<div class="glr-kpis">
<div class="glr-kpi glr-kpi--teal">
<div class="glr-kpi-n">&#8377;{{ clientDeposits | number: '1.2-2' }}</div>
<div class="glr-kpi-l">Customer Deposits</div>
</div>
<div class="glr-kpi glr-kpi--amber">
<div class="glr-kpi-n">&#8377;{{ clientAdvances | number: '1.2-2' }}</div>
<div class="glr-kpi-l">Customer Advances</div>
</div>
<div class="glr-kpi glr-kpi--green">
<div class="glr-kpi-n">{{ clientRows.length | number }}</div>
<div class="glr-kpi-l">Relationships</div>
<div class="glr-kpi-sub">as at {{ range.to }} · {{ branchLabel }}</div>
</div>
</div>
<p class="glr-note">
Customer balances are derived from the General Ledger — each relationship holds a share of its branch's balance
in that head — so this report always reconciles to the GL rather than drifting as a second source.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>CIF</th>
<th>Customer</th>
<th>Branch</th>
<th>Account No</th>
<th>Product</th>
<th class="ta-c">Side</th>
<th class="ta-r">Balance (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (r of clientRows; track r.cif) {
<tr>
<td class="mono">{{ r.cif }}</td>
<td>{{ r.name }}</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="mono">{{ r.accountNo }}</td>
<td class="muted">{{ r.product }}</td>
<td class="ta-c">
<span
class="type-pill"
[class.type-liability]="r.side === 'DEPOSIT'"
[class.type-asset]="r.side === 'ADVANCE'"
>
{{ r.side }}
</span>
</td>
<td class="ta-r mono">{{ r.balance | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="glr-empty">No customer relationships for this period and branch.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ BANKER HEAD REPORT ══════════════ -->
@if (activeReport === 'banker-head') {
<div class="glr-section">
<p class="glr-note">
Head-wise abstract for <strong>{{ rangeLabel }}</strong> — the trial balance rolled up to principal heads, with
opening, turnover and closing for each. Debit and credit turnover must agree.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
<thead>
<tr>
<th>Principal Head</th>
<th class="ta-c">Accounts</th>
<th class="ta-r">Opening</th>
<th class="ta-r">Debit</th>
<th class="ta-r">Credit</th>
<th class="ta-r">Closing</th>
</tr>
</thead>
<tbody>
@for (r of bankerHeadRows; track r.head) {
<tr>
<td>
<strong>{{ r.head }}</strong>
</td>
<td class="ta-c mono">{{ r.accounts }}</td>
<td class="ta-r mono">{{ r.opening | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.debit ? (r.debit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.credit ? (r.credit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">
<strong>{{ r.closing | number: '1.2-2' }}</strong>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="glr-empty">No activity for this period and branch.</td>
</tr>
}
<tr class="total-row">
<td colspan="2">TOTAL ({{ bankerHeadRows.length }} heads)</td>
<td></td>
<td class="ta-r mono">{{ headDebitTotal | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ headCreditTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ SUNDRY REGISTER ══════════════ -->
@if (activeReport === 'sundry-register') {
<div class="glr-section">
<p class="glr-note">
Outstanding items in the sundry, suspense and bills-payable heads as at <strong>{{ range.to }}</strong
>, aged from their posting date. These heads are where unreconciled entries collect, so ageing is the point of
the register.
</p>
@if (sundryBuckets.length) {
<div class="glr-kpis">
@for (b of sundryBuckets; track b.bucket) {
<div
class="glr-kpi"
[class.glr-kpi--green]="b.bucket === 'Up to 30 days'"
[class.glr-kpi--amber]="b.bucket === '31 to 90 days'"
[class.glr-kpi--red]="b.bucket === 'Over 180 days'"
>
<div class="glr-kpi-n">&#8377;{{ b.amount | number: '1.2-2' }}</div>
<div class="glr-kpi-l">{{ b.bucket }}</div>
<div class="glr-kpi-sub">{{ b.count }} item(s)</div>
</div>
}
</div>
}
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Branch</th>
<th>GL Code</th>
<th>Account</th>
<th>Narration</th>
<th class="ta-r">Amount (&#8377;)</th>
<th class="ta-c">Age</th>
</tr>
</thead>
<tbody>
@for (r of sundryRows; track r.voucherNo + r.code + r.date) {
<tr>
<td class="mono">{{ r.date }}</td>
<td class="mono">{{ r.voucherNo }}</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.narration }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
<td class="ta-c">
<span
class="type-pill"
[class.type-income]="r.bucket === 'Up to 30 days'"
[class.type-expense]="r.bucket === '31 to 90 days'"
[class.type-contingent]="r.bucket === 'Over 180 days'"
>
{{ r.ageDays }}d
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="glr-empty">No sundry or suspense items outstanding for this selection.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="6">Total outstanding ({{ sundryRows.length }} items)</td>
<td class="ta-r mono">{{ sundryTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- ══════════════ VOUCHER REGISTER ══════════════ -->
@if (activeReport === 'voucher-register') {
<div class="glr-section">
<div class="glr-balance" [class.glr-balance--bad]="unbalancedVouchers > 0">
<fa-icon [icon]="unbalancedVouchers ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
<span>
{{ voucherRows.length | number }} voucher(s) raised in {{ rangeLabel }} for {{ branchLabel }} —
{{
unbalancedVouchers
? unbalancedVouchers + ' voucher(s) do NOT balance and must be investigated'
: 'every voucher is double-entry and balances'
}}
</span>
</div>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Branch</th>
<th>Narration</th>
<th>Debit account(s)</th>
<th>Credit account(s)</th>
<th class="ta-r">Amount (&#8377;)</th>
<th class="ta-c">Legs</th>
</tr>
</thead>
<tbody>
@for (r of voucherRows; track r.voucherNo) {
<tr [class.glr-unbalanced]="!r.balanced">
<td class="mono">{{ r.date }}</td>
<td class="mono">{{ r.voucherNo }}</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="muted">{{ r.narration }}</td>
<td class="mono">{{ r.debitAccounts }}</td>
<td class="mono">{{ r.creditAccounts }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
<td class="ta-c mono">{{ r.legs }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="glr-empty">No vouchers raised in this period for this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="6">Total ({{ voucherRows.length }} vouchers)</td>
<td class="ta-r mono">{{ voucherTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- TOAST -->
<div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -160,6 +160,21 @@
}
}
/* Read-only branch shown to a branch-level login in place of the selector. */
.glr-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
min-width: 190px;
border: 1px solid var(--g-g200);
border-radius: 8px;
background: var(--g-g100);
font-size: 13px;
font-weight: 600;
color: var(--g-g700);
}
/* Day / Month / Range segmented control */
.glr-seg {
display: inline-flex;
@@ -434,6 +449,45 @@
font-style: italic;
}
/* A voucher whose debits and credits disagree. */
.glr-unbalanced td {
background: var(--g-red-l) !important;
color: #7f1d1d;
font-weight: 600;
}
/* GL code chips on the mapping report */
.glr-gl-chip {
display: inline-block;
padding: 1px 8px;
margin: 1px 3px 1px 0;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 11px;
font-weight: 600;
}
/* Share bars on the distribution report */
.glr-bar-col {
width: 150px;
}
.glr-bar {
height: 9px;
border-radius: 999px;
background: var(--g-g100);
overflow: hidden;
min-width: 110px;
}
.glr-bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--g-navy), var(--g-blue));
transition: width 0.25s;
}
/* Account-type pills on the GL Wise report */
.type-pill {
display: inline-block;
+743 -33
View File
@@ -6,24 +6,42 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { BranchScopeService } from 'app/core/branch-scope.service';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import { GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
import { GL_ACCOUNTS, GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
import {
AccountBranchRow,
AverageBalanceRow,
BalanceSheetRow,
ComparativeRow,
DistributionRow,
GlDateRange,
GlReportSet,
MiscExpenseRow,
PnlRow,
BankerHeadRow,
ClientBalanceRow,
SundryItemRow,
VoucherRow,
accountWiseByBranch,
averageBalances,
balanceDistribution,
bankerHeadSummary,
clientWiseBalances,
sundryRegister,
voucherRegister,
buildReports,
clampToLedger,
glValueForCodes,
sum
} from './gl-reports.compute';
import { GlMappingEntry, allMappings, glLabel, returnName } from 'app/rbi-circulars/gl-mapping';
export type GlReportCode =
| 'trial-balance'
@@ -32,7 +50,15 @@ export type GlReportCode =
| 'comparative-balance-sheet'
| 'contingent-liabilities'
| 'gl-wise-dr-cr'
| 'misc-expense-ledger';
| 'misc-expense-ledger'
| 'gl-average-balance'
| 'gl-mapping'
| 'account-wise-balance'
| 'distribution-of-balance'
| 'client-wise-balance'
| 'banker-head'
| 'sundry-register'
| 'voucher-register';
type PeriodMode = 'day' | 'month' | 'range';
@@ -49,6 +75,19 @@ type PeriodMode = 'day' | 'month' | 'range';
export class GlReportsComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private scope = inject(BranchScopeService);
private destroyRef = inject(DestroyRef);
/** Head Office may pick any branch; a branch login is locked to its own book. */
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get scopeIsUnresolved(): boolean {
return this.scope.isUnresolved;
}
get scopeName(): string {
return this.scope.scopeLabel(this.branchCode);
}
readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' },
@@ -57,7 +96,15 @@ export class GlReportsComponent implements OnInit {
{ code: 'comparative-balance-sheet', label: 'Comparative Balance Sheet', icon: 'balance-scale' },
{ code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' },
{ code: 'gl-wise-dr-cr', label: 'GL Wise DR / CR', icon: 'project-diagram' },
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' }
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' },
{ code: 'gl-average-balance', label: 'GL Average Balance', icon: 'calculator' },
{ code: 'gl-mapping', label: 'GL Mapping for Report', icon: 'project-diagram' },
{ code: 'account-wise-balance', label: 'Account wise Balance List', icon: 'list' },
{ code: 'distribution-of-balance', label: 'Distribution of Balance', icon: 'chart-pie' },
{ code: 'client-wise-balance', label: 'Client wise Balance', icon: 'users' },
{ code: 'banker-head', label: 'Banker Head Report', icon: 'university' },
{ code: 'sundry-register', label: 'Sundry Register', icon: 'clipboard-list' },
{ code: 'voucher-register', label: 'Voucher Register', icon: 'receipt' }
];
activeReport: GlReportCode = 'trial-balance';
@@ -80,16 +127,29 @@ export class GlReportsComponent implements OnInit {
private toastTimer: any;
ngOnInit(): void {
const param = this.route.snapshot.paramMap.get('report') as GlReportCode | null;
if (param && this.reports.some((r) => r.code === param)) {
this.activeReport = param;
}
// Subscribe rather than read the snapshot: navigating between GL menu items only
// changes the route parameter, so Angular reuses this component and ngOnInit would
// never fire again — leaving the previously selected report on screen.
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const param = params.get('report') as GlReportCode | null;
if (param && this.reports.some((r) => r.code === param)) {
this.activeReport = param;
}
});
// A branch login opens on its own book and cannot move off it.
this.branchCode = this.scope.defaultBranchCode;
}
showReport(code: GlReportCode): void {
this.activeReport = code;
// Keep the URL deep-linkable so the GL menu items land on the right report.
this.router.navigate(['/gl/reports', code], { replaceUrl: true });
this.router.navigate(
[
'/gl/reports',
code
],
{ replaceUrl: true }
);
}
get reportLabel(): string {
@@ -120,21 +180,33 @@ export class GlReportsComponent implements OnInit {
}
get branchLabel(): string {
if (!this.canSelectBranch) {
return this.scopeName;
}
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
}
/**
* The branch actually used for every query. Applied at the data boundary rather than
* only in the UI so a branch login cannot widen its scope by tampering with state.
*/
private get effectiveBranch(): string {
return this.scope.enforce(this.branchCode);
}
resetFilters(): void {
this.periodMode = 'month';
this.filterMonth = '2026-07';
this.filterDay = '2026-07-31';
this.filterFrom = GL_PERIOD.fyStart;
this.filterTo = GL_PERIOD.fyEnd;
this.branchCode = '';
// Reset returns a branch login to its own branch, never to "all".
this.branchCode = this.scope.defaultBranchCode;
}
/** All seven reports for the current filter — memoised inside buildReports. */
get data(): GlReportSet {
return buildReports(this.range, this.branchCode);
return buildReports(this.range, this.effectiveBranch);
}
// ── Trial Balance ──────────────────────────────────────────────────────────
@@ -234,6 +306,139 @@ export class GlReportsComponent implements OnInit {
return glBranchName(code);
}
// ── GL Average Balance ─────────────────────────────────────────────────────
get averageRows(): AverageBalanceRow[] {
return averageBalances(this.range, this.effectiveBranch);
}
get avgTotalAverage(): number {
return sum(this.averageRows, (r) => r.average);
}
// ── GL Mapping for Report ──────────────────────────────────────────────────
/** Every RBI return line currently pinned to GL accounts, with its ledger value. */
get mappingRows(): {
returnCode: string;
returnLabel: string;
lineKey: string;
codes: string[];
codeLabels: string;
value: number;
}[] {
return allMappings()
.map((m: GlMappingEntry) => ({
returnCode: m.returnCode,
returnLabel: returnName(m.returnCode),
lineKey: m.lineKey,
codes: m.codes,
codeLabels: m.codes.map((c) => glLabel(c)).join(', '),
// Returns are in thousands; the ledger is in rupees.
value: glValueForCodes(m.codes, this.range, this.effectiveBranch) / 1000
}))
.sort((a, b) => a.returnCode.localeCompare(b.returnCode) || a.lineKey.localeCompare(b.lineKey));
}
/** GL accounts not pinned to any return line — the gap a mapping review looks for. */
get unmappedAccounts(): { code: string; name: string; group: string }[] {
const used = new Set(allMappings().flatMap((m) => m.codes));
return this.chartOfAccounts
.filter((a) => !used.has(a.code))
.map((a) => ({ code: a.code, name: a.name, group: a.group }));
}
readonly chartOfAccounts = GL_ACCOUNTS;
// ── Account wise Balance List ──────────────────────────────────────────────
/** Branch columns shown — every branch for Head Office, just their own otherwise. */
get branchColumns(): string[] {
return this.canSelectBranch ? GL_BRANCHES.map((b) => b.code) : [this.effectiveBranch];
}
get accountWiseRows(): AccountBranchRow[] {
return accountWiseByBranch(this.range, this.branchColumns);
}
branchColumnTotal(code: string): number {
return sum(this.accountWiseRows, (r) => r.byBranch[code] ?? 0);
}
get accountWiseGrandTotal(): number {
return sum(this.accountWiseRows, (r) => r.total);
}
// ── Client wise Balance ────────────────────────────────────────────────────
get clientRows(): ClientBalanceRow[] {
return clientWiseBalances(this.range, this.effectiveBranch);
}
get clientDeposits(): number {
return sum(
this.clientRows.filter((r) => r.side === 'DEPOSIT'),
(r) => r.balance
);
}
get clientAdvances(): number {
return sum(
this.clientRows.filter((r) => r.side === 'ADVANCE'),
(r) => r.balance
);
}
// ── Banker Head Report ─────────────────────────────────────────────────────
get bankerHeadRows(): BankerHeadRow[] {
return bankerHeadSummary(this.range, this.effectiveBranch);
}
get headDebitTotal(): number {
return sum(this.bankerHeadRows, (r) => r.debit);
}
get headCreditTotal(): number {
return sum(this.bankerHeadRows, (r) => r.credit);
}
// ── Sundry Register ────────────────────────────────────────────────────────
get sundryRows(): SundryItemRow[] {
return sundryRegister(this.range, this.effectiveBranch);
}
get sundryTotal(): number {
return sum(this.sundryRows, (r) => r.amount);
}
/** Ageing summary shown above the register. */
get sundryBuckets(): { bucket: string; count: number; amount: number }[] {
const order = [
'Up to 30 days',
'31 to 90 days',
'91 to 180 days',
'Over 180 days'
];
return order
.map((bucket) => {
const rows = this.sundryRows.filter((r) => r.bucket === bucket);
return { bucket, count: rows.length, amount: sum(rows, (r) => r.amount) };
})
.filter((b) => b.count > 0);
}
// ── Voucher Register ───────────────────────────────────────────────────────
get voucherRows(): VoucherRow[] {
return voucherRegister(this.range, this.effectiveBranch);
}
get voucherTotal(): number {
return sum(this.voucherRows, (r) => r.amount);
}
/** Vouchers whose debits and credits do not agree — should always be zero. */
get unbalancedVouchers(): number {
return this.voucherRows.filter((r) => !r.balanced).length;
}
// ── Distribution of Balance ────────────────────────────────────────────────
get assetDistribution(): DistributionRow[] {
return balanceDistribution(this.range, this.effectiveBranch, 'ASSET');
}
get liabilityDistribution(): DistributionRow[] {
return balanceDistribution(this.range, this.effectiveBranch, 'LIABILITY');
}
get assetDistTotal(): number {
return sum(this.assetDistribution, (r) => r.amount);
}
get liabilityDistTotal(): number {
return sum(this.liabilityDistribution, (r) => r.amount);
}
// ── Excel export ───────────────────────────────────────────────────────────
/** Exports whichever report is on screen, carrying the active filter into the header. */
@@ -314,7 +519,17 @@ export class GlReportsComponent implements OnInit {
'Closing Dr',
'Closing Cr'
],
widths: [12, 42, 26, 16, 16, 16, 16, 16, 16],
widths: [
12,
42,
26,
16,
16,
16,
16,
16,
16
],
rows: [
...this.data.trialBalance.map((r) => [
r.code,
@@ -349,13 +564,49 @@ export class GlReportsComponent implements OnInit {
'Group',
'Amount'
],
widths: [14, 12, 42, 26, 18],
widths: [
14,
12,
42,
26,
18
],
rows: [
...this.pnlExpense.map((r) => ['Expenditure', r.code, r.name, r.group, r.amount]),
['Total Expenditure', '', '', '', this.totalExpense],
...this.pnlIncome.map((r) => ['Income', r.code, r.name, r.group, r.amount]),
['Total Income', '', '', '', this.totalIncome],
['Net Profit', '', '', '', this.netProfit]
...this.pnlExpense.map((r) => [
'Expenditure',
r.code,
r.name,
r.group,
r.amount
]),
[
'Total Expenditure',
'',
'',
'',
this.totalExpense
],
...this.pnlIncome.map((r) => [
'Income',
r.code,
r.name,
r.group,
r.amount
]),
[
'Total Income',
'',
'',
'',
this.totalIncome
],
[
'Net Profit',
'',
'',
'',
this.netProfit
]
]
};
case 'balance-sheet':
@@ -367,13 +618,49 @@ export class GlReportsComponent implements OnInit {
'Group',
'Amount'
],
widths: [14, 12, 42, 26, 18],
widths: [
14,
12,
42,
26,
18
],
rows: [
...this.bsLiabilities.map((r) => ['Liability', r.code, r.name, r.group, r.amount]),
['Liability', '', 'Surplus for the period', 'Profit & Loss', this.bsSurplus],
['Total Liabilities', '', '', '', this.bsTotalLiabilities],
...this.bsAssets.map((r) => ['Asset', r.code, r.name, r.group, r.amount]),
['Total Assets', '', '', '', this.bsTotalAssets]
...this.bsLiabilities.map((r) => [
'Liability',
r.code,
r.name,
r.group,
r.amount
]),
[
'Liability',
'',
'Surplus for the period',
'Profit & Loss',
this.bsSurplus
],
[
'Total Liabilities',
'',
'',
'',
this.bsTotalLiabilities
],
...this.bsAssets.map((r) => [
'Asset',
r.code,
r.name,
r.group,
r.amount
]),
[
'Total Assets',
'',
'',
'',
this.bsTotalAssets
]
]
};
case 'comparative-balance-sheet':
@@ -387,7 +674,15 @@ export class GlReportsComponent implements OnInit {
'Variance',
'Variance %'
],
widths: [12, 12, 42, 18, 18, 18, 14],
widths: [
12,
12,
42,
18,
18,
18,
14
],
rows: this.data.comparative.map((r) => [
r.side,
r.code,
@@ -406,10 +701,25 @@ export class GlReportsComponent implements OnInit {
'Group',
'Amount'
],
widths: [12, 48, 26, 18],
widths: [
12,
48,
26,
18
],
rows: [
...this.data.contingent.map((r) => [r.code, r.name, r.group, r.amount]),
['TOTAL', '', '', this.contingentTotal]
...this.data.contingent.map((r) => [
r.code,
r.name,
r.group,
r.amount
]),
[
'TOTAL',
'',
'',
this.contingentTotal
]
]
};
case 'gl-wise-dr-cr':
@@ -424,10 +734,394 @@ export class GlReportsComponent implements OnInit {
'Net (Dr-Cr)',
'Vouchers'
],
widths: [12, 42, 14, 26, 18, 18, 18, 12],
widths: [
12,
42,
14,
26,
18,
18,
18,
12
],
rows: [
...this.data.glWise.map((r) => [r.code, r.name, r.type, r.group, r.debit, r.credit, r.net, r.vouchers]),
['TOTAL', '', '', '', this.glWiseDebit, this.glWiseCredit, this.glWiseDebit - this.glWiseCredit, null]
...this.data.glWise.map((r) => [
r.code,
r.name,
r.type,
r.group,
r.debit,
r.credit,
r.net,
r.vouchers
]),
[
'TOTAL',
'',
'',
'',
this.glWiseDebit,
this.glWiseCredit,
this.glWiseDebit - this.glWiseCredit,
null
]
]
};
case 'gl-average-balance':
return {
headers: [
'GL Code',
'Account',
'Group',
'Opening',
'Closing',
'Average Daily Balance',
'Peak',
'Lowest',
'Active Days'
],
widths: [
12,
42,
26,
18,
18,
22,
18,
18,
12
],
rows: [
...this.averageRows.map((r) => [
r.code,
r.name,
r.group,
r.opening,
r.closing,
r.average,
r.peak,
r.lowest,
r.activeDays
]),
[
'TOTAL',
'',
'',
null,
null,
this.avgTotalAverage,
null,
null,
null
]
]
};
case 'gl-mapping':
return {
headers: [
'Return',
'Return Line',
'Mapped GL Accounts',
'Ledger Value (Rs Thousands)'
],
widths: [
42,
46,
60,
24
],
rows: [
...this.mappingRows.map((r) => [
r.returnLabel,
r.lineKey,
r.codeLabels,
r.value
]),
...(this.mappingRows.length ? [] : [[
'— no return lines mapped —',
'',
'',
null
]]),
[
'',
'',
'',
null
],
[
'UNMAPPED GL ACCOUNTS',
'',
'',
null
],
...this.unmappedAccounts.map((a) => [
'',
a.code + ' — ' + a.name,
a.group,
null
])
]
};
case 'account-wise-balance':
return {
headers: [
'GL Code',
'Account',
'Group',
...this.branchColumns.map((c) => this.branchName(c)),
'Total'
],
widths: [
12,
40,
24,
...this.branchColumns.map(() => 18),
20
],
rows: [
...this.accountWiseRows.map((r) => [
r.code,
r.name,
r.group,
...this.branchColumns.map((c) => r.byBranch[c] ?? 0),
r.total
]),
[
'TOTAL',
'',
'',
...this.branchColumns.map((c) => this.branchColumnTotal(c)),
this.accountWiseGrandTotal
]
]
};
case 'distribution-of-balance':
return {
headers: [
'Side',
'Group',
'Accounts',
'Amount',
'Share %'
],
widths: [
16,
40,
14,
22,
14
],
rows: [
...this.liabilityDistribution.map((r) => [
'Liability',
r.group,
r.accounts,
r.amount,
Number(r.share.toFixed(2))
]),
[
'Total Liabilities',
'',
null,
this.liabilityDistTotal,
100
],
...this.assetDistribution.map((r) => [
'Asset',
r.group,
r.accounts,
r.amount,
Number(r.share.toFixed(2))
]),
[
'Total Assets',
'',
null,
this.assetDistTotal,
100
]
]
};
case 'client-wise-balance':
return {
headers: [
'CIF',
'Customer',
'Branch',
'Account No',
'Product',
'Side',
'Balance'
],
widths: [
14,
34,
18,
24,
34,
12,
20
],
rows: [
...this.clientRows.map((r) => [
r.cif,
r.name,
this.branchName(r.branchCode),
r.accountNo,
r.product,
r.side,
r.balance
]),
[
'TOTAL DEPOSITS',
'',
'',
'',
'',
'',
this.clientDeposits
],
[
'TOTAL ADVANCES',
'',
'',
'',
'',
'',
this.clientAdvances
]
]
};
case 'banker-head':
return {
headers: [
'Principal Head',
'Accounts',
'Opening',
'Debit',
'Credit',
'Closing'
],
widths: [
34,
12,
20,
20,
20,
20
],
rows: [
...this.bankerHeadRows.map((r) => [
r.head,
r.accounts,
r.opening,
r.debit,
r.credit,
r.closing
]),
[
'TOTAL',
null,
null,
this.headDebitTotal,
this.headCreditTotal,
null
]
]
};
case 'sundry-register':
return {
headers: [
'Date',
'Voucher No',
'Branch',
'GL Code',
'Account',
'Narration',
'Amount',
'Age (days)',
'Ageing Bucket'
],
widths: [
14,
20,
18,
12,
32,
42,
18,
12,
18
],
rows: [
...this.sundryRows.map((r) => [
r.date,
r.voucherNo,
this.branchName(r.branchCode),
r.code,
r.name,
r.narration,
r.amount,
r.ageDays,
r.bucket
]),
[
'TOTAL',
'',
'',
'',
'',
'',
this.sundryTotal,
null,
''
]
]
};
case 'voucher-register':
return {
headers: [
'Date',
'Voucher No',
'Branch',
'Narration',
'Debit Account(s)',
'Credit Account(s)',
'Amount',
'Legs'
],
widths: [
14,
20,
18,
42,
38,
38,
20,
10
],
rows: [
...this.voucherRows.map((r) => [
r.date,
r.voucherNo,
this.branchName(r.branchCode),
r.narration,
r.debitAccounts,
r.creditAccounts,
r.amount,
r.legs
]),
[
'TOTAL',
'',
'',
'',
'',
'',
this.voucherTotal,
null
]
]
};
case 'misc-expense-ledger':
@@ -442,7 +1136,15 @@ export class GlReportsComponent implements OnInit {
'Narration',
'Amount'
],
widths: [14, 20, 18, 12, 36, 46, 18],
widths: [
14,
20,
18,
12,
36,
46,
18
],
rows: [
...this.miscRows.map((r) => [
r.date,
@@ -453,7 +1155,15 @@ export class GlReportsComponent implements OnInit {
r.narration,
r.amount
]),
['TOTAL', '', '', '', '', '', this.miscTotal]
[
'TOTAL',
'',
'',
'',
'',
'',
this.miscTotal
]
]
};
}
+477 -13
View File
@@ -15,14 +15,7 @@
* postings and Angular re-evaluates template getters on every change detection.
*/
import {
GL_ACCOUNTS,
GL_PERIOD,
GlAccount,
GlPosting,
glAccount,
glPostings
} from './gl-reports-data';
import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data';
export interface GlDateRange {
from: string;
@@ -96,8 +89,22 @@ function matchesBranch(p: GlPosting, branchCode: string): boolean {
return !branchCode || p.branchCode === branchCode;
}
/**
* Both scans below walk the whole ~21k-posting ledger, so their results are cached
* per (date, branch). Angular re-evaluates template getters on every change-detection
* pass; without this a screen with a few hundred mapped lines would spend seconds per
* click rescanning the ledger and appear frozen.
*/
const balanceCache = new Map<string, Map<string, number>>();
const turnoverCache = new Map<string, Map<string, { debit: number; credit: number; vouchers: Set<string> }>>();
/** Signed balance on the account's natural side, for postings up to and including `to`. */
function cumulativeBalances(to: string, branchCode: string): Map<string, number> {
const key = `${to}|${branchCode}`;
const hit = balanceCache.get(key);
if (hit) {
return hit;
}
const out = new Map<string, number>();
for (const p of glPostings()) {
if (p.date > to || !matchesBranch(p, branchCode)) {
@@ -110,11 +117,20 @@ function cumulativeBalances(to: string, branchCode: string): Map<string, number>
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
}
balanceCache.set(key, out);
return out;
}
/** Debit and credit turnover strictly inside the range. */
function periodTurnover(range: GlDateRange, branchCode: string): Map<string, { debit: number; credit: number; vouchers: Set<string> }> {
function periodTurnover(
range: GlDateRange,
branchCode: string
): Map<string, { debit: number; credit: number; vouchers: Set<string> }> {
const key = `${range.from}|${range.to}|${branchCode}`;
const hit = turnoverCache.get(key);
if (hit) {
return hit;
}
const out = new Map<string, { debit: number; credit: number; vouchers: Set<string> }>();
for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
@@ -129,6 +145,7 @@ function periodTurnover(range: GlDateRange, branchCode: string): Map<string, { d
entry.credit += p.credit;
entry.vouchers.add(p.voucherNo);
}
turnoverCache.set(key, out);
return out;
}
@@ -190,8 +207,7 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
if (!move) {
continue;
}
const amount =
account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
const amount = account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
if (!amount) {
continue;
}
@@ -205,7 +221,10 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
* Balance sheet as at the period end. The current-period surplus is folded in as
* a liability line so the two sides tally the way the printed statement does.
*/
export function balanceSheet(range: GlDateRange, branchCode: string): {
export function balanceSheet(
range: GlDateRange,
branchCode: string
): {
assets: BalanceSheetRow[];
liabilities: BalanceSheetRow[];
surplus: number;
@@ -315,7 +334,417 @@ export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscE
amount: p.debit
});
}
return rows.sort((a, b) => (a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)));
return rows.sort((a, b) =>
a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)
);
}
export interface AverageBalanceRow {
code: string;
name: string;
group: string;
type: string;
opening: number;
closing: number;
average: number;
peak: number;
lowest: number;
/** Days on which the account moved. */
activeDays: number;
}
export interface AccountBranchRow {
code: string;
name: string;
group: string;
/** Closing balance per branch code, plus a `total`. */
byBranch: Record<string, number>;
total: number;
}
export interface DistributionRow {
group: string;
amount: number;
share: number;
accounts: number;
}
/** Calendar days in the range, inclusive. */
function daysInRange(range: GlDateRange): string[] {
const out: string[] = [];
const cur = new Date(`${range.from}T00:00:00Z`);
const end = new Date(`${range.to}T00:00:00Z`);
while (cur <= end) {
out.push(cur.toISOString().slice(0, 10));
cur.setUTCDate(cur.getUTCDate() + 1);
}
return out;
}
/**
* Average daily balance per account across the period, plus the peak and lowest
* balance touched. Banks use average balances rather than closing snapshots for
* interest, CRR/SLR and profitability work, because a closing figure can be
* window-dressed on the last day.
*
* Computed by carrying a running balance forward day by day rather than
* recomputing a cumulative balance per day, which would be quadratic.
*/
export function averageBalances(range: GlDateRange, branchCode: string): AverageBalanceRow[] {
const opening = cumulativeBalances(previousDay(range.from), branchCode);
const days = daysInRange(range);
// Net movement per account per day, on the account's natural side.
const dailyDelta = new Map<string, Map<string, number>>();
const movedOn = new Map<string, Set<string>>();
for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
continue;
}
const account = glAccount(p.accountCode);
if (!account) {
continue;
}
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
let perDay = dailyDelta.get(p.accountCode);
if (!perDay) {
perDay = new Map<string, number>();
dailyDelta.set(p.accountCode, perDay);
}
perDay.set(p.date, (perDay.get(p.date) ?? 0) + delta);
let seen = movedOn.get(p.accountCode);
if (!seen) {
seen = new Set<string>();
movedOn.set(p.accountCode, seen);
}
seen.add(p.date);
}
const rows: AverageBalanceRow[] = [];
for (const account of GL_ACCOUNTS) {
const open = opening.get(account.code) ?? 0;
const perDay = dailyDelta.get(account.code);
if (!open && !perDay) {
continue;
}
let running = open;
let total = 0;
let peak = open;
let lowest = open;
for (const d of days) {
running += perDay?.get(d) ?? 0;
total += running;
peak = Math.max(peak, running);
lowest = Math.min(lowest, running);
}
rows.push({
code: account.code,
name: account.name,
group: account.group,
type: account.type,
opening: open,
closing: running,
average: days.length ? total / days.length : 0,
peak,
lowest,
activeDays: movedOn.get(account.code)?.size ?? 0
});
}
return rows;
}
/**
* Closing balance of every account broken out by branch. Answers "which branch is
* carrying this balance", which a single consolidated column cannot.
*/
export function accountWiseByBranch(range: GlDateRange, branchCodes: string[]): AccountBranchRow[] {
const perBranch = new Map<string, Map<string, number>>();
for (const b of branchCodes) {
perBranch.set(b, cumulativeBalances(range.to, b));
}
const rows: AccountBranchRow[] = [];
for (const account of GL_ACCOUNTS) {
const byBranch: Record<string, number> = {};
let total = 0;
let any = false;
for (const b of branchCodes) {
const v = perBranch.get(b)?.get(account.code) ?? 0;
byBranch[b] = v;
total += v;
if (v) {
any = true;
}
}
if (!any) {
continue;
}
rows.push({ code: account.code, name: account.name, group: account.group, byBranch, total });
}
return rows;
}
/**
* Share of the closing book held by each account group, for one side of the
* balance sheet. Used to see concentration at a glance.
*/
export function balanceDistribution(
range: GlDateRange,
branchCode: string,
side: 'ASSET' | 'LIABILITY'
): DistributionRow[] {
const closing = cumulativeBalances(range.to, branchCode);
const byGroup = new Map<string, { amount: number; accounts: number }>();
let grand = 0;
for (const account of GL_ACCOUNTS) {
if (account.type !== side) {
continue;
}
const v = closing.get(account.code) ?? 0;
if (!v) {
continue;
}
const entry = byGroup.get(account.group) ?? { amount: 0, accounts: 0 };
entry.amount += v;
entry.accounts += 1;
byGroup.set(account.group, entry);
grand += v;
}
return [...byGroup.entries()]
.map(
([
group,
e
]) => ({
group,
amount: e.amount,
accounts: e.accounts,
share: grand ? (e.amount / grand) * 100 : 0
})
)
.sort((a, b) => b.amount - a.amount);
}
export interface ClientBalanceRow {
cif: string;
name: string;
branchCode: string;
accountNo: string;
accountCode: string;
product: string;
/** DEPOSIT or ADVANCE — which side of the relationship this is. */
side: 'DEPOSIT' | 'ADVANCE';
balance: number;
}
export interface BankerHeadRow {
head: string;
opening: number;
debit: number;
credit: number;
closing: number;
accounts: number;
}
export interface SundryItemRow {
date: string;
voucherNo: string;
branchCode: string;
code: string;
name: string;
narration: string;
amount: number;
/** Days outstanding as at the period end. */
ageDays: number;
bucket: string;
}
export interface VoucherRow {
voucherNo: string;
date: string;
branchCode: string;
narration: string;
debitAccounts: string;
creditAccounts: string;
amount: number;
legs: number;
/** False when debits and credits on the voucher do not agree. */
balanced: boolean;
}
/**
* Client-wise balances, derived from the GL rather than stored separately so the two
* can never disagree. Each customer's balance is its share of the branch's closing
* balance in the head it sits under.
*/
export function clientWiseBalances(range: GlDateRange, branchCode: string): ClientBalanceRow[] {
const rows: ClientBalanceRow[] = [];
const branchClosings = new Map<string, Map<string, number>>();
for (const c of glCustomers()) {
if (branchCode && c.branchCode !== branchCode) {
continue;
}
let closing = branchClosings.get(c.branchCode);
if (!closing) {
closing = cumulativeBalances(range.to, c.branchCode);
branchClosings.set(c.branchCode, closing);
}
const headBalance = closing.get(c.accountCode) ?? 0;
const balance = headBalance * c.share;
if (Math.abs(balance) < 0.005) {
continue;
}
const account = glAccount(c.accountCode);
rows.push({
cif: c.cif,
name: c.name,
branchCode: c.branchCode,
accountNo: c.accountNo,
accountCode: c.accountCode,
product: account?.name ?? c.accountCode,
side: c.accountCode.startsWith('13') ? 'ADVANCE' : 'DEPOSIT',
balance
});
}
return rows.sort((a, b) => b.balance - a.balance);
}
/**
* Head-wise abstract: the trial balance rolled up to principal heads, the summary a
* banker reads before the detail. Opening, turnover and closing per head.
*/
export function bankerHeadSummary(range: GlDateRange, branchCode: string): BankerHeadRow[] {
const opening = cumulativeBalances(previousDay(range.from), branchCode);
const turnover = periodTurnover(range, branchCode);
const byHead = new Map<string, BankerHeadRow>();
for (const account of GL_ACCOUNTS) {
// Contingents are single-sided off-balance-sheet memoranda. Including them would
// make the abstract's debit and credit turnover disagree and stop it reconciling
// to the trial balance, which also excludes them. They have their own report.
if (account.type === 'CONTINGENT') {
continue;
}
const open = opening.get(account.code) ?? 0;
const move = turnover.get(account.code);
if (!open && !move) {
continue;
}
const debit = move?.debit ?? 0;
const credit = move?.credit ?? 0;
const closing = open + (isDebitSide(account) ? debit - credit : credit - debit);
const row = byHead.get(account.group) ?? {
head: account.group,
opening: 0,
debit: 0,
credit: 0,
closing: 0,
accounts: 0
};
row.opening += open;
row.debit += debit;
row.credit += credit;
row.closing += closing;
row.accounts += 1;
byHead.set(account.group, row);
}
return [...byHead.values()];
}
const SUNDRY_CODES = [
'140104',
'230103',
'140199',
'230102'
];
/**
* Outstanding items in the sundry and suspense heads, aged from the posting date.
* These accounts are where unreconciled entries accumulate, so ageing is the point
* of the register.
*/
export function sundryRegister(range: GlDateRange, branchCode: string): SundryItemRow[] {
const wanted = new Set(SUNDRY_CODES);
const end = new Date(`${range.to}T00:00:00Z`).getTime();
const rows: SundryItemRow[] = [];
for (const p of glPostings()) {
if (p.date > range.to || !matchesBranch(p, branchCode) || !wanted.has(p.accountCode)) {
continue;
}
const ageDays = Math.max(0, Math.round((end - new Date(`${p.date}T00:00:00Z`).getTime()) / 86400000));
const bucket =
ageDays <= 30
? 'Up to 30 days'
: ageDays <= 90
? '31 to 90 days'
: ageDays <= 180
? '91 to 180 days'
: 'Over 180 days';
rows.push({
date: p.date,
voucherNo: p.voucherNo,
branchCode: p.branchCode,
code: p.accountCode,
name: glAccount(p.accountCode)?.name ?? p.accountCode,
narration: p.narration,
amount: p.debit || p.credit,
ageDays,
bucket
});
}
return rows.sort((a, b) => b.ageDays - a.ageDays);
}
/**
* Every voucher raised in the period with its debit and credit legs collapsed onto
* one line, plus a per-voucher double-entry check.
*/
export function voucherRegister(range: GlDateRange, branchCode: string): VoucherRow[] {
const grouped = new Map<string, GlPosting[]>();
for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
continue;
}
const list = grouped.get(p.voucherNo);
if (list) {
list.push(p);
} else {
grouped.set(p.voucherNo, [p]);
}
}
const rows: VoucherRow[] = [];
for (const [
voucherNo,
legs
] of grouped) {
const debits = legs.filter((l) => l.debit > 0);
const credits = legs.filter((l) => l.credit > 0);
const drTotal = debits.reduce((s, l) => s + l.debit, 0);
const crTotal = credits.reduce((s, l) => s + l.credit, 0);
const nameOf = (codes: GlPosting[], side: 'debit' | 'credit') =>
[...new Set(codes.map((l) => `${l.accountCode} ${glAccount(l.accountCode)?.name ?? ''}`.trim()))].join(', ');
rows.push({
voucherNo,
date: legs[0].date,
branchCode: legs[0].branchCode,
narration: legs[0].narration,
debitAccounts: nameOf(debits, 'debit') || '—',
creditAccounts: nameOf(credits, 'credit') || '—',
amount: Math.max(drTotal, crTotal),
legs: legs.length,
// Contingent and opening memoranda are single-sided by design.
balanced: voucherNo.startsWith('CNT/') || voucherNo.startsWith('OB/') || Math.round(drTotal - crTotal) === 0
});
}
return rows.sort((a, b) =>
a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)
);
}
// ── Memoisation ──────────────────────────────────────────────────────────────
@@ -360,6 +789,41 @@ export function clampToLedger(range: GlDateRange): GlDateRange {
};
}
/**
* Value of a set of GL accounts for a period, used when an RBI return line item is
* mapped to the chart of accounts.
*
* Balance-sheet accounts contribute their closing balance as at the period end;
* income and expenditure accounts contribute their movement within the period.
* That mirrors how the two kinds of figure are read on a return.
*/
export function glValueForCodes(codes: string[], range: GlDateRange, branchCode = ''): number {
if (!codes.length) {
return 0;
}
const wanted = new Set(codes);
const closing = cumulativeBalances(range.to, branchCode);
const movement = periodTurnover(range, branchCode);
let total = 0;
for (const code of wanted) {
const account = glAccount(code);
if (!account) {
continue;
}
if (account.type === 'INCOME' || account.type === 'EXPENSE') {
const move = movement.get(code);
if (!move) {
continue;
}
total += account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
} else {
total += closing.get(code) ?? 0;
}
}
return total;
}
export function sum<T>(rows: T[], pick: (row: T) => number): number {
return rows.reduce((acc, r) => acc + pick(r), 0);
}
@@ -21,6 +21,22 @@
</mat-card-header>
<mat-card-content class="aml-card-content">
<!-- Separate the rules by the window they are measured over -->
<div class="period-filters">
<span class="period-filters-label">Applies over</span>
<button
*ngFor="let p of periodFilters"
type="button"
class="period-chip"
[class.active]="activePeriod === p.key"
[disabled]="periodCount(p.key) === 0"
(click)="setPeriod(p.key)"
>
{{ p.label }}
<span class="period-chip-n">{{ periodCount(p.key) }}</span>
</button>
</div>
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
<!-- Order Position Column -->
<ng-container matColumnDef="orderPosition">
@@ -45,10 +61,37 @@
</td>
</ng-container>
<!-- Income / Occupation Limit Column -->
<ng-container matColumnDef="incomeLimit">
<th mat-header-cell *matHeaderCellDef>Income / Occupation Limit</th>
<td mat-cell *matCellDef="let element" class="income-limit-cell">{{ getIncomeLimitLabel(element) }}</td>
<!-- What the min/max figures are measured against -->
<ng-container matColumnDef="limitBasis">
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
<td mat-cell *matCellDef="let element" class="basis-cell">
{{ getLimitBasis(element) }}
<span *ngIf="getNonNumericCriterion(element)" class="basis-sub">
{{ getNonNumericCriterion(element) }}
</span>
</td>
</ng-container>
<!-- Minimum Limit Column -->
<ng-container matColumnDef="minLimit">
<th mat-header-cell *matHeaderCellDef class="col-right">Minimum Limit</th>
<td mat-cell *matCellDef="let element" class="col-right amount-cell">{{ getMinLimit(element) }}</td>
</ng-container>
<!-- Maximum Limit Column -->
<ng-container matColumnDef="maxLimit">
<th mat-header-cell *matHeaderCellDef class="col-right">Maximum Limit</th>
<td mat-cell *matCellDef="let element" class="col-right amount-cell">
<span [class.no-ceiling]="getMaxLimit(element) === 'No upper limit'">{{ getMaxLimit(element) }}</span>
</td>
</ng-container>
<!-- Observation window the rule is measured over -->
<ng-container matColumnDef="appliesOver">
<th mat-header-cell *matHeaderCellDef class="col-center">Applies Over</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span [ngClass]="periodClass(element)">{{ periodLabel(element) }}</span>
</td>
</ng-container>
<!-- Reason Column -->
@@ -114,13 +114,141 @@
max-width: 400px;
}
.income-limit-cell {
.col-right {
text-align: right;
justify-content: flex-end;
}
/* What the min/max amounts are measured against. */
.basis-cell {
color: #334155;
font-size: 13px;
font-weight: 500;
font-size: 12px;
font-weight: 600;
white-space: normal;
max-width: 200px;
line-height: 1.35;
max-width: 150px;
line-height: 1.3;
}
.basis-sub {
display: block;
font-size: 11px;
font-weight: 400;
color: #64748b;
}
.amount-cell {
font-family: 'DM Mono', monospace;
font-size: 12.5px;
color: #0f172a;
white-space: nowrap;
}
/* An open-ended threshold rule has a floor but no ceiling. */
.no-ceiling {
font-family: inherit;
font-size: 11px;
font-style: italic;
color: #94a3b8;
}
/* ── Observation-window badges ─────────────────────────────────────────── */
.period-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
letter-spacing: 0.02em;
&.period-txn {
background: #e2e8f0;
color: #334155;
}
&.period-day {
background: #dbeafe;
color: #1d4ed8;
}
&.period-week {
background: #fef3c7;
color: #b45309;
}
&.period-month {
background: #ede9fe;
color: #6d28d9;
}
&.period-other {
background: #fee2e2;
color: #b91c1c;
}
}
/* Chips that separate the rule list by window. */
.period-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 0 0 14px;
}
.period-filters-label {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-right: 2px;
}
.period-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 13px;
border: 1px solid #cbd5e1;
background: #fff;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
&:hover:not(:disabled) {
border-color: #1a237e;
color: #1a237e;
}
&.active {
background: #1a237e;
border-color: #1a237e;
color: #fff;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.period-chip-n {
font-size: 11px;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
.period-chip.active & {
background: rgb(255 255 255 / 22%);
color: #fff;
}
}
/* Badges for Risk Category */
@@ -42,24 +42,185 @@ export class AmlRulesComponent implements OnInit {
'orderPosition',
'ruleName',
'riskCategory',
'incomeLimit',
'limitBasis',
'minLimit',
'maxLimit',
'appliesOver',
'reason',
'amlCheckStatus',
'bannedEntityMatch'
];
/** Every rule as returned by the API, before the period filter is applied. */
private allRules: any[] = [];
/** Period buckets the rules can be separated into. */
readonly periodFilters: { key: string; label: string }[] = [
{ key: '', label: 'All rules' },
{ key: 'TXN', label: 'Per transaction' },
{ key: 'DAY', label: 'Daily' },
{ key: 'WEEK', label: 'Weekly' },
{ key: 'MONTH', label: 'Monthly' },
{ key: 'OTHER', label: 'Other window' }
];
activePeriod = '';
@ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void {
this.clientsService.getClientRiskRules().subscribe({
next: (data) => {
this.rules.data = data || [];
this.allRules = data || [];
this.applyPeriodFilter();
this.rules.paginator = this.paginator;
},
error: () => {}
});
}
setPeriod(key: string): void {
this.activePeriod = key;
this.applyPeriodFilter();
}
private applyPeriodFilter(): void {
this.rules.data = this.activePeriod
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
: this.allRules;
if (this.rules.paginator) {
this.rules.paginator.firstPage();
}
}
/** How many rules fall in each period bucket — shown on the filter chips. */
periodCount(key: string): number {
return key ? this.allRules.filter((r) => this.periodKey(r) === key).length : this.allRules.length;
}
/**
* Buckets a rule by the observation window it is measured over.
* A rule with no window is evaluated on the single transaction itself, so it is
* in force every day rather than accumulating over a period.
*/
periodKey(rule: any): string {
const days = rule.txnWindowDays;
if (days == null || days === 0) {
return 'TXN';
}
if (days === 1) {
return 'DAY';
}
if (days === 7) {
return 'WEEK';
}
if (days >= 28 && days <= 31) {
return 'MONTH';
}
return 'OTHER';
}
periodLabel(rule: any): string {
const days = rule.txnWindowDays;
switch (this.periodKey(rule)) {
case 'TXN':
return 'Per transaction';
case 'DAY':
return 'Daily';
case 'WEEK':
return 'Weekly';
case 'MONTH':
return 'Monthly';
default:
return `${days} days`;
}
}
periodClass(rule: any): string {
return 'period-badge period-' + this.periodKey(rule).toLowerCase();
}
/** Sub-caption telling the reader what the min/max figures actually measure. */
getLimitBasis(rule: any): string {
if (rule.minMonthlyIncome > 0 || rule.maxMonthlyIncome > 0) {
return 'Monthly Income';
}
if (rule.minAnnualIncome > 0 || rule.maxAnnualIncome > 0) {
return 'Annual Income';
}
if (rule.minAnnualTurnover > 0 || rule.maxAnnualTurnover > 0) {
return 'Annual Turnover';
}
if (rule.txnThresholdAmountMediumRisk != null || rule.txnThresholdAmountHighRisk != null) {
return 'Txn Amount (tiered)';
}
if (rule.txnThresholdAmount != null) {
return 'Txn Amount';
}
if (rule.occupationName) {
return 'Occupation';
}
if (rule.occupationCategoryName) {
return 'Occupation Category';
}
return 'Behavioural pattern';
}
private inr(n: number): string {
return '₹' + Number(n).toLocaleString('en-IN');
}
/** Lower bound of the rule, or an em dash where the criterion is not numeric. */
getMinLimit(rule: any): string {
if (rule.minMonthlyIncome > 0) {
return this.inr(rule.minMonthlyIncome);
}
if (rule.minAnnualIncome > 0) {
return this.inr(rule.minAnnualIncome);
}
if (rule.minAnnualTurnover > 0) {
return this.inr(rule.minAnnualTurnover);
}
// Declarative band from m_aml_transaction_rule takes precedence over the raw threshold.
if (rule.txnMinLimitAmount != null) {
return this.inr(rule.txnMinLimitAmount);
}
if (rule.txnThresholdAmount != null) {
return this.inr(rule.txnThresholdAmount);
}
return '—';
}
/** Upper bound, or "No upper limit" where the rule is genuinely open-ended. */
getMaxLimit(rule: any): string {
if (rule.maxMonthlyIncome > 0) {
return this.inr(rule.maxMonthlyIncome);
}
if (rule.maxAnnualIncome > 0) {
return this.inr(rule.maxAnnualIncome);
}
if (rule.maxAnnualTurnover > 0) {
return this.inr(rule.maxAnnualTurnover);
}
if (rule.txnMaxLimitAmount != null) {
return this.inr(rule.txnMaxLimitAmount);
}
// A floor with no configured ceiling means the rule has no upper bound.
if (
rule.minMonthlyIncome > 0 ||
rule.minAnnualIncome > 0 ||
rule.minAnnualTurnover > 0 ||
rule.txnThresholdAmount != null
) {
return 'No upper limit';
}
return '—';
}
/** Occupation-driven rules carry no amount; show the criterion instead. */
getNonNumericCriterion(rule: any): string {
return rule.occupationName || rule.occupationCategoryName || '';
}
getRiskCategoryLabel(category: number): string {
switch (category) {
case 1:
@@ -85,39 +246,4 @@ export class AmlRulesComponent implements OnInit {
return '';
}
}
/** Formats whichever income/turnover/occupation criterion drives this rule's risk mark. */
getIncomeLimitLabel(rule: any): string {
const inr = (n: number) => '₹' + Number(n).toLocaleString('en-IN');
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0) {
return rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0
? `Monthly Income ${inr(rule.minMonthlyIncome)} - ${inr(rule.maxMonthlyIncome)}`
: `Monthly Income > ${inr(rule.minMonthlyIncome)}`;
}
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0) {
return rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0
? `Annual Income ${inr(rule.minAnnualIncome)} - ${inr(rule.maxAnnualIncome)}`
: `Annual Income > ${inr(rule.minAnnualIncome)}`;
}
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0) {
return rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0
? `Annual Turnover ${inr(rule.minAnnualTurnover)} - ${inr(rule.maxAnnualTurnover)}`
: `Annual Turnover > ${inr(rule.minAnnualTurnover)}`;
}
if (rule.occupationName) return `Occupation: ${rule.occupationName}`;
if (rule.occupationCategoryName) return `Occupation Category: ${rule.occupationCategoryName}`;
if (rule.txnThresholdAmountMediumRisk != null || rule.txnThresholdAmountHighRisk != null) {
const parts: string[] = [];
if (rule.txnThresholdAmountMediumRisk != null) parts.push(`${inr(rule.txnThresholdAmountMediumRisk)} (Medium Risk)`);
if (rule.txnThresholdAmountHighRisk != null) parts.push(`${inr(rule.txnThresholdAmountHighRisk)} (High Risk)`);
return `Txn Amount ≥ ${parts.join(' / ')}`;
}
if (rule.txnThresholdAmount != null) {
const amt = inr(rule.txnThresholdAmount);
if (rule.txnRuleType === 'THRESHOLD') return `Txn Amount ≥ ${amt}`;
if (rule.txnWindowDays != null) return `${amt} within ${rule.txnWindowDays} days`;
return `Txn Amount ≥ ${amt}`;
}
return 'Behavioral / Transaction Pattern';
}
}
@@ -0,0 +1,126 @@
<!--
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="glm-root">
<div class="glm-head">
<div>
<h3>Map General Ledger accounts</h3>
<p>{{ data.returnCode }} &middot; {{ data.lineLabel }}</p>
</div>
<button class="glm-btn" (click)="cancel()"><fa-icon icon="times"></fa-icon></button>
</div>
<!-- Live comparison between the ledger and the figure on the return -->
<div
class="glm-compare"
[class.glm-compare--match]="selected.length && matchesFiled"
[class.glm-compare--diff]="selected.length && !matchesFiled && variance !== null"
>
<div class="glm-fig">
<span class="glm-fig-l">On the return</span>
<span class="glm-fig-v">{{ data.filedValue === null ? '—' : (data.filedValue | number: '1.2-2') }}</span>
</div>
<fa-icon icon="exchange-alt" class="glm-fig-ic"></fa-icon>
<div class="glm-fig">
<span class="glm-fig-l">From the ledger</span>
<span class="glm-fig-v">{{ selected.length ? (derivedValue | number: '1.2-2') : '—' }}</span>
</div>
@if (selected.length && variance !== null) {
<div class="glm-fig glm-fig--var">
<span class="glm-fig-l">Variance</span>
<span class="glm-fig-v">{{ variance | number: '1.2-2' }}</span>
</div>
}
<div class="glm-compare-note">
@if (!selected.length) {
Select accounts below to derive this line from the ledger.
} @else if (matchesFiled) {
Ledger agrees with the filed figure.
} @else {
Ledger differs from the filed figure — the mapped value will be shown on the return.
}
</div>
</div>
<!-- Currently pinned accounts -->
<div class="glm-selected">
<div class="glm-selected-h">
<span>Mapped accounts ({{ selected.length }})</span>
@if (selected.length) {
<button class="glm-link" (click)="clearAll()">Remove all</button>
}
</div>
@if (selected.length) {
<div class="glm-chips">
@for (code of selected; track code) {
<span class="glm-chip">
<span class="mono">{{ code }}</span> {{ accountName(code) }}
<button class="glm-chip-x" (click)="removeSelected(code)" [attr.aria-label]="'Remove ' + code">
<fa-icon icon="times"></fa-icon>
</button>
</span>
}
</div>
} @else {
<p class="glm-empty">No accounts mapped — this line still uses the figure filed on the return.</p>
}
</div>
<!-- Chart of accounts picker -->
<div class="glm-filters">
<input class="glm-inp" type="text" placeholder="Search by code, name or group…" [(ngModel)]="search" />
<select class="glm-inp glm-inp--sm" [(ngModel)]="typeFilter">
<option value="">All types</option>
@for (t of types; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</div>
<div class="glm-list">
<table class="glm-tbl">
<thead>
<tr>
<th class="ta-c">Map</th>
<th>GL Code</th>
<th>Account</th>
<th>Type</th>
<th>Group</th>
</tr>
</thead>
<tbody>
@for (a of filtered; track a.code) {
<tr [class.on]="isSelected(a.code)" (click)="toggle(a.code)">
<td class="ta-c">
<span class="glm-check" [class.on]="isSelected(a.code)">
@if (isSelected(a.code)) {
<fa-icon icon="check-circle"></fa-icon>
}
</span>
</td>
<td class="mono">{{ a.code }}</td>
<td>{{ a.name }}</td>
<td>
<span class="glm-type" [class]="'t-' + a.type.toLowerCase()">{{ a.type }}</span>
</td>
<td class="muted">{{ a.group }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="glm-empty-row">No accounts match that search.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="glm-actions">
<button class="glm-btn" (click)="cancel()">Cancel</button>
<button class="glm-btn glm-btn-primary" (click)="save()">Save mapping ({{ selected.length }})</button>
</div>
</div>
@@ -0,0 +1,358 @@
/**
* 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 {
--m-navy: #002f5f;
--m-green: #15803d;
--m-green-l: #dcfce7;
--m-amber: #b45309;
--m-amber-l: #fef3c7;
--m-red: #c0392b;
--m-red-l: #fdecea;
--m-g50: #f8fafc;
--m-g100: #f1f5f9;
--m-g200: #e2e8f0;
--m-g300: #cbd5e1;
--m-g500: #64748b;
--m-g700: #334155;
--m-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--m-g900);
}
.glm-root {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 20px;
max-height: 88vh;
}
.glm-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
h3 {
margin: 0;
font-size: 17px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
color: var(--m-g500);
}
}
/* Ledger vs filed figure */
.glm-compare {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
padding: 12px 16px;
border-radius: 10px;
background: var(--m-g100);
border: 1px solid var(--m-g200);
&--match {
background: var(--m-green-l);
border-color: #86efac;
}
&--diff {
background: var(--m-amber-l);
border-color: #fcd34d;
}
}
.glm-fig {
display: flex;
flex-direction: column;
&--var .glm-fig-v {
color: var(--m-amber);
}
}
.glm-fig-l {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--m-g500);
}
.glm-fig-v {
font-family: 'DM Mono', monospace;
font-size: 16px;
font-weight: 700;
}
.glm-fig-ic {
color: var(--m-g500);
}
.glm-compare-note {
flex: 1 1 220px;
font-size: 12px;
color: var(--m-g700);
}
/* Pinned accounts */
.glm-selected-h {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--m-g500);
margin-bottom: 7px;
}
.glm-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.glm-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px 4px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 12px;
font-weight: 500;
}
.glm-chip-x {
border: none;
background: rgb(7 89 133 / 12%);
color: #075985;
width: 18px;
height: 18px;
border-radius: 50%;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
&:hover {
background: var(--m-red);
color: #fff;
}
}
.glm-empty {
margin: 0;
font-size: 12px;
color: var(--m-g500);
font-style: italic;
}
/* Picker */
.glm-filters {
display: flex;
gap: 8px;
}
.glm-inp {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--m-g200);
border-radius: 8px;
font-size: 13px;
font-family: inherit;
&--sm {
flex: 0 0 160px;
}
&:focus {
outline: none;
border-color: var(--m-navy);
}
}
.glm-list {
flex: 1;
min-height: 180px;
max-height: 40vh;
overflow: auto;
border: 1px solid var(--m-g200);
border-radius: 8px;
}
.glm-tbl {
width: 100%;
border-collapse: collapse;
th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 8px 12px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--m-g500);
background: var(--m-g50);
border-bottom: 1px solid var(--m-g200);
}
td {
padding: 7px 12px;
border-bottom: 1px solid var(--m-g100);
font-size: 13px;
color: var(--m-g700);
}
tbody tr {
cursor: pointer;
&:hover td {
background: var(--m-g50);
}
&.on td {
background: #eff6ff;
}
}
}
.glm-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 2px solid var(--m-g300);
border-radius: 5px;
color: transparent;
font-size: 11px;
&.on {
background: var(--m-green);
border-color: var(--m-green);
color: #fff;
}
}
.glm-type {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: var(--m-g100);
color: var(--m-g700);
&.t-asset {
background: #dbeafe;
color: #1d4ed8;
}
&.t-liability {
background: #e0f4f0;
color: #0e7e6b;
}
&.t-income {
background: var(--m-green-l);
color: var(--m-green);
}
&.t-expense {
background: var(--m-amber-l);
color: var(--m-amber);
}
&.t-contingent {
background: var(--m-red-l);
color: var(--m-red);
}
}
.glm-empty-row {
text-align: center;
padding: 26px;
color: var(--m-g500);
font-style: italic;
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 12px;
}
.muted {
color: var(--m-g500);
font-size: 11px;
}
.ta-c {
text-align: center;
}
/* Actions */
.glm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.glm-btn {
padding: 8px 16px;
border: 1px solid var(--m-g300);
background: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--m-g700);
cursor: pointer;
font-family: inherit;
&:hover {
background: var(--m-g50);
}
}
.glm-btn-primary {
background: var(--m-navy);
border-color: var(--m-navy);
color: #fff;
&:hover {
background: #013a75;
color: #fff;
}
}
.glm-link {
border: none;
background: none;
color: var(--m-red);
font-size: 11px;
font-weight: 700;
cursor: pointer;
text-transform: none;
letter-spacing: 0;
}
@@ -0,0 +1,121 @@
/**
* 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, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { GlAccount } from 'app/gl-reports/gl-reports-data';
import { GlDateRange, glValueForCodes } from 'app/gl-reports/gl-reports.compute';
import { RETURN_SCALE, glAccountsForPicker } from './gl-mapping';
export interface GlMappingDialogData {
returnCode: string;
lineLabel: string;
/** The figure currently printed on the return, in thousands. */
filedValue: number | null;
selected: string[];
range: GlDateRange;
branchCode: string;
}
/**
* Lets a user pin one or more GL accounts to a single return line, showing live what
* the ledger would produce for the selection against the figure currently on the return.
*/
@Component({
selector: 'mifosx-gl-mapping-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
FaIconComponent
],
templateUrl: './gl-mapping-dialog.component.html',
styleUrls: ['./gl-mapping-dialog.component.scss']
})
export class GlMappingDialogComponent {
private dialogRef = inject(MatDialogRef<GlMappingDialogComponent>);
data = inject<GlMappingDialogData>(MAT_DIALOG_DATA);
readonly accounts: GlAccount[] = glAccountsForPicker();
search = '';
typeFilter = '';
selected: string[] = [];
readonly types = [
'ASSET',
'LIABILITY',
'INCOME',
'EXPENSE',
'CONTINGENT'
];
constructor() {
this.selected = [...(this.data.selected ?? [])];
}
get filtered(): GlAccount[] {
const q = this.search.trim().toLowerCase();
return this.accounts.filter((a) => {
if (this.typeFilter && a.type !== this.typeFilter) {
return false;
}
if (!q) {
return true;
}
return a.code.includes(q) || a.name.toLowerCase().includes(q) || a.group.toLowerCase().includes(q);
});
}
isSelected(code: string): boolean {
return this.selected.includes(code);
}
toggle(code: string): void {
this.selected = this.isSelected(code) ? this.selected.filter((c) => c !== code) : [
...this.selected,
code
];
}
removeSelected(code: string): void {
this.selected = this.selected.filter((c) => c !== code);
}
clearAll(): void {
this.selected = [];
}
accountName(code: string): string {
return this.accounts.find((a) => a.code === code)?.name ?? code;
}
/** Live ledger figure for the current selection, in the return's scale. */
get derivedValue(): number {
return glValueForCodes(this.selected, this.data.range, this.data.branchCode) / RETURN_SCALE;
}
get variance(): number | null {
return this.data.filedValue == null ? null : this.derivedValue - this.data.filedValue;
}
get matchesFiled(): boolean {
return this.variance !== null && Math.abs(this.variance) < 0.005;
}
save(): void {
this.dialogRef.close(this.selected);
}
cancel(): void {
this.dialogRef.close(undefined);
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { BranchScopeService } from 'app/core/branch-scope.service';
import { GlDateRange } from 'app/gl-reports/gl-reports.compute';
import { GL_BRANCHES } from 'app/gl-reports/gl-reports-data';
import { GlMappingDialogComponent, GlMappingDialogData } from './gl-mapping-dialog.component';
import {
clearReturnMappings,
getMapping,
lineKeyFor,
mappedLineCount,
mappedValue,
removeGl,
setMapping
} from './gl-mapping';
/**
* Shared GL-mapping behaviour for the OSS return screens.
*
* Each return line can be pinned to GL accounts; where it is, the figure shown comes
* from the ledger rather than the static filed value. Subclasses only supply their
* return code — everything else, including the ledger period and branch scope, is
* common.
*/
export abstract class GlMappingHost {
private dialog = inject(MatDialog);
private branchScope = inject(BranchScopeService);
/** e.g. 'R070'. Used to namespace this return's mappings. */
abstract readonly glReturnCode: string;
/** Ledger period the mapped figures are drawn from. */
abstract get glRange(): GlDateRange;
/** Whether the per-line mapping controls are visible. */
glMapMode = false;
/** '' = all branches consolidated. Seeded from the login's own scope. */
glBranchCode = this.branchScope.defaultBranchCode;
readonly glBranches = GL_BRANCHES;
/** Head Office may derive figures from any branch; a branch login cannot. */
get canSelectGlBranch(): boolean {
return this.branchScope.canSelectBranch;
}
get glScopeName(): string {
return this.branchScope.scopeLabel(this.glBranchCode);
}
/** Branch actually used for every ledger lookup, enforced rather than trusted. */
private get effectiveGlBranch(): string {
return this.branchScope.enforce(this.glBranchCode);
}
toggleGlMapMode(): void {
this.glMapMode = !this.glMapMode;
}
glLineKey(label: string): string {
return lineKeyFor(label);
}
glCodesFor(label: string): string[] {
return getMapping(this.glReturnCode, this.glLineKey(label));
}
isGlMapped(label: string): boolean {
return this.glCodesFor(label).length > 0;
}
get glMappedCount(): number {
return mappedLineCount(this.glReturnCode);
}
/** Ledger figure for a line, or null when it has no mapping. */
glValue(label: string): number | null {
return mappedValue(this.glReturnCode, this.glLineKey(label), this.glRange, this.effectiveGlBranch);
}
/**
* The number to print for a line: the ledger value when mapped, otherwise the
* value filed on the return.
*/
effectiveValue(label: string, filed: number | null): number | null {
const mapped = this.glValue(label);
return mapped === null ? filed : mapped;
}
/** True when a mapped line disagrees with the figure originally filed. */
glDiffers(label: string, filed: number | null): boolean {
const mapped = this.glValue(label);
if (mapped === null || filed === null) {
return false;
}
return Math.abs(mapped - filed) >= 0.005;
}
removeGlFromLine(label: string, code: string): void {
removeGl(this.glReturnCode, this.glLineKey(label), code);
}
clearAllGlMappings(): void {
clearReturnMappings(this.glReturnCode);
}
/** Opens the chart-of-accounts picker for one return line. */
openGlPicker(label: string, filed: number | null): void {
const key = this.glLineKey(label);
const data: GlMappingDialogData = {
returnCode: this.glReturnCode,
lineLabel: label,
filedValue: filed,
selected: getMapping(this.glReturnCode, key),
range: this.glRange,
branchCode: this.effectiveGlBranch
};
this.dialog
.open(GlMappingDialogComponent, {
width: '92vw',
maxWidth: '980px',
maxHeight: '92vh',
autoFocus: false,
data
})
.afterClosed()
.subscribe((codes: string[] | undefined) => {
if (codes) {
setMapping(this.glReturnCode, key, codes);
}
});
}
}
+170
View File
@@ -0,0 +1,170 @@
/**
* 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/.
*/
/**
* Maps RBI return line items to General Ledger accounts.
*
* A return line can be pinned to one or more GL codes; the line's figure is then
* derived from the ledger instead of being a static number, so the filed return and
* the books cannot drift apart silently. Mappings are keyed by return code plus a
* stable line key and persisted locally.
*
* The OSS returns are denominated in thousands while the ledger is in rupees, so
* every derived figure is scaled on the way out.
*/
import { GL_ACCOUNTS, GlAccount } from 'app/gl-reports/gl-reports-data';
import { GlDateRange, glValueForCodes } from 'app/gl-reports/gl-reports.compute';
const STORAGE_KEY = 'rbi_return_gl_mappings';
/** Returns are reported in thousands of rupees. */
export const RETURN_SCALE = 1000;
export type GlMappingStore = Record<string, string[]>;
function mapKey(returnCode: string, lineKey: string): string {
return `${returnCode}::${lineKey}`;
}
/**
* The store is read from template getters on every change-detection pass, so it is
* held in memory and only re-parsed when a write invalidates it. Parsing JSON a few
* hundred times per click is otherwise a measurable cost on the larger returns.
*/
let storeCache: GlMappingStore | null = null;
function readStore(): GlMappingStore {
if (storeCache) {
return storeCache;
}
try {
storeCache = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as GlMappingStore;
} catch {
storeCache = {};
}
return storeCache;
}
function writeStore(store: GlMappingStore): void {
storeCache = store;
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
}
/** GL codes currently pinned to a return line. */
export function getMapping(returnCode: string, lineKey: string): string[] {
return readStore()[mapKey(returnCode, lineKey)] ?? [];
}
export function setMapping(returnCode: string, lineKey: string, codes: string[]): void {
const store = readStore();
const key = mapKey(returnCode, lineKey);
if (codes.length) {
store[key] = codes;
} else {
delete store[key];
}
writeStore(store);
}
export function addGl(returnCode: string, lineKey: string, code: string): void {
const current = getMapping(returnCode, lineKey);
if (!current.includes(code)) {
setMapping(returnCode, lineKey, [
...current,
code
]);
}
}
export function removeGl(returnCode: string, lineKey: string, code: string): void {
setMapping(
returnCode,
lineKey,
getMapping(returnCode, lineKey).filter((c) => c !== code)
);
}
/** Every line in a return that currently has a mapping. */
export function mappedLineCount(returnCode: string): number {
const prefix = `${returnCode}::`;
return Object.keys(readStore()).filter((k) => k.startsWith(prefix)).length;
}
export function clearReturnMappings(returnCode: string): void {
const store = readStore();
const prefix = `${returnCode}::`;
for (const k of Object.keys(store)) {
if (k.startsWith(prefix)) {
delete store[k];
}
}
writeStore(store);
}
/**
* Ledger value for a mapped line, expressed in the return's own scale (thousands).
* Returns null when the line has no mapping, so callers can fall back to the filed figure.
*/
export function mappedValue(returnCode: string, lineKey: string, range: GlDateRange, branchCode = ''): number | null {
const codes = getMapping(returnCode, lineKey);
if (!codes.length) {
return null;
}
return glValueForCodes(codes, range, branchCode) / RETURN_SCALE;
}
/** A line key that stays stable across reorderings — derived from the label itself. */
export function lineKeyFor(label: string): string {
return label
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 80);
}
export interface GlMappingEntry {
returnCode: string;
lineKey: string;
codes: string[];
}
/** Every mapping across all returns — drives the GL Mapping for Report listing. */
export function allMappings(): GlMappingEntry[] {
return Object.entries(readStore()).map(
([
key,
codes
]) => {
const [
returnCode,
lineKey
] = key.split('::');
return { returnCode, lineKey, codes };
}
);
}
/** Human-readable name for a return code. */
export function returnName(code: string): string {
const names: Record<string, string> = {
R070: 'OSS 1 — Return on Assets and Liabilities',
R071: 'OSS 2 — Return on Earnings',
R323: 'OSS 3 — Return on Asset Quality'
};
return names[code] ?? code;
}
export function glAccountsForPicker(): GlAccount[] {
return GL_ACCOUNTS;
}
export function glLabel(code: string): string {
const a = GL_ACCOUNTS.find((x) => x.code === code);
return a ? `${a.code} — ${a.name}` : code;
}
+152
View File
@@ -360,6 +360,158 @@ td.derived {
color: var(--o-g500);
}
/* ── General Ledger mapping ─────────────────────────────────────────────── */
.oss-gl-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
flex-wrap: wrap;
padding: 10px 16px;
border-radius: 10px;
background: #fff;
border: 1px solid var(--o-g200);
&.on {
background: var(--o-blue-l);
border-color: #93c5fd;
}
}
.oss-gl-bar-l {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
color: var(--o-g700);
}
.oss-gl-bar-r {
display: flex;
align-items: center;
gap: 8px;
}
.oss-gl-count {
display: inline-block;
margin-left: 8px;
padding: 2px 9px;
border-radius: 999px;
background: var(--o-green-l);
color: var(--o-green);
font-size: 11px;
font-weight: 700;
}
.oss-gl-inp {
padding: 7px 11px;
border: 1px solid var(--o-g300);
border-radius: 8px;
font-size: 12.5px;
font-family: inherit;
background: #fff;
}
.oss-btn-clear {
color: var(--o-red);
border-color: var(--o-red);
}
.oss-gl-col {
min-width: 240px;
}
/* Read-only branch shown to a branch-level login in place of the selector. */
.oss-gl-scope {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--o-g300);
border-radius: 8px;
background: var(--o-g100);
font-size: 12.5px;
font-weight: 600;
color: var(--o-g700);
}
.oss-gl-cell {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
.oss-gl-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 8px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 11px;
font-weight: 600;
}
.oss-gl-chip-x {
border: none;
background: rgb(7 89 133 / 14%);
color: #075985;
width: 15px;
height: 15px;
border-radius: 50%;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 8px;
&:hover {
background: var(--o-red);
color: #fff;
}
}
.oss-gl-add {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border: 1px dashed var(--o-g300);
background: #fff;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--o-g500);
cursor: pointer;
font-family: inherit;
&:hover {
border-color: var(--o-navy);
color: var(--o-navy);
border-style: solid;
}
}
.oss-gl-warn {
font-size: 10.5px;
font-weight: 700;
color: var(--o-amber-d);
white-space: nowrap;
}
/* A figure sourced from the ledger rather than the filed return. */
td.gl-sourced {
background: #ecfdf5;
box-shadow: inset 3px 0 0 var(--o-green);
}
td.gl-diff {
background: #fffbeb;
box-shadow: inset 3px 0 0 #d97706;
}
/* Tie-up status pills (cross-return reconciliation checks). */
.report-pill {
display: inline-block;
+36 -25
View File
@@ -7,13 +7,7 @@
*/
import * as ExcelJS from 'exceljs';
import {
MatrixTable,
ReturnGeneralInfoRow,
ReturnLine,
ReturnSignatory,
SIGNATORY_FIELDS
} from './oss-return.model';
import { MatrixTable, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory, SIGNATORY_FIELDS } from './oss-return.model';
/** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */
export const XL = {
@@ -45,12 +39,7 @@ export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: stri
sheet.addRow([]);
}
export function boxAllCells(
sheet: ExcelJS.Worksheet,
from: number,
to: number,
lastColIndex: number
): void {
export function boxAllCells(sheet: ExcelJS.Worksheet, from: number, to: number, lastColIndex: number): void {
for (let r = from; r <= to; r++) {
for (let c = 1; c <= lastColIndex; c++) {
sheet.getRow(r).getCell(c).border = {
@@ -64,7 +53,10 @@ export function boxAllCells(
}
export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void {
const row = sheet.addRow([label, unitLabel ?? '']);
const row = sheet.addRow([
label,
unitLabel ?? ''
]);
row.font = { bold: true };
row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey)));
row.getCell(2).alignment = { horizontal: 'right' };
@@ -73,7 +65,10 @@ export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLa
/** Renders a labelled block; indents by level and shades derived totals. */
export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void {
for (const line of lines) {
const row = sheet.addRow([line.label, line.value]);
const row = sheet.addRow([
line.label,
line.value
]);
const labelCell = row.getCell(1);
const valueCell = row.getCell(2);
@@ -98,7 +93,10 @@ export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): vo
const colCount = table.columns.length + 1;
const start = sheet.rowCount + 1;
const titleRow = sheet.addRow([table.title, ...(table.unit ? [table.unit] : [])]);
const titleRow = sheet.addRow([
table.title,
...(table.unit ? [table.unit] : [])
]);
if (!table.unit) {
sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount);
}
@@ -115,13 +113,19 @@ export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): vo
sub.getCell(1).fill = solidFill(XL.headerGrey);
}
const head = sheet.addRow(['Particulars', ...table.columns]);
const head = sheet.addRow([
'Particulars',
...table.columns
]);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const row of table.rows) {
const excelRow = sheet.addRow([row.label, ...row.values]);
const excelRow = sheet.addRow([
row.label,
...row.values
]);
const labelCell = excelRow.getCell(1);
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level };
if (row.level === 0 || row.header) {
@@ -158,13 +162,15 @@ export function buildGeneralInfoSheet(wb: ExcelJS.Workbook, rows: ReturnGeneralI
const sheet = wb.addWorksheet('General Information');
sheet.columns = [
{ width: 26 },
{ width: 58 }
];
{ width: 58 }];
addBanner(sheet, 'General Information', 'B');
const first = sheet.rowCount + 1;
for (const item of rows) {
const row = sheet.addRow([item.label, item.value]);
const row = sheet.addRow([
item.label,
item.value
]);
row.getCell(1).fill = solidFill(XL.keyCell);
// The workbook tints the audit flag yellow and leaves unfilled cells grey.
const fill = item.label === 'Audit Flag' ? XL.auditYellow : item.value ? XL.derivedBlue : XL.headerGrey;
@@ -182,19 +188,24 @@ export function buildSignatorySheet(
const sheet = wb.addWorksheet(sheetName);
sheet.columns = [
{ width: 44 },
{ width: 24 }
];
{ width: 24 }];
addBanner(sheet, sheetName, 'B');
for (const person of signatories) {
const start = sheet.rowCount + 1;
const head = sheet.addRow([person.heading, '']);
const head = sheet.addRow([
person.heading,
''
]);
head.font = { bold: true };
head.alignment = { horizontal: 'center' };
head.getCell(1).fill = solidFill(XL.keyCell);
for (const f of SIGNATORY_FIELDS) {
const row = sheet.addRow([person[f.key], f.caption]);
const row = sheet.addRow([
person[f.key],
f.caption
]);
row.getCell(2).font = { bold: true };
row.getCell(2).alignment = { horizontal: 'center' };
row.getCell(2).fill = solidFill(XL.keyCell);
+100 -20
View File
@@ -153,8 +153,7 @@ export const RAL_LIABILITIES: RalLine[] = [
{ label: 'ii. Borrowings', level: 1, value: null },
{
label:
'12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
label: '12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
level: 0,
value: 39440,
total: true
@@ -192,7 +191,12 @@ export const RAL_ASSETS: RalLine[] = [
{ label: '2. I. Due from Banks', level: 0, value: 57600, total: true },
{ label: 'A. Balances with Banks', level: 1, value: 57600, total: true },
{ label: 'i. State/ Central Cooperative Bank of the State/District concerned in', level: 2, value: 32268, total: true },
{
label: 'i. State/ Central Cooperative Bank of the State/District concerned in',
level: 2,
value: 32268,
total: true
},
{ label: 'a. Current', level: 2, value: 5980 },
{ label: 'b. Savings', level: 2, value: null },
{ label: 'c. Fixed', level: 2, value: 26288 },
@@ -353,26 +357,73 @@ export const RAL_AGEING_HEADERS: string[] = [
/** Sheet 3 — Annexure 1 :: 3. Age-wise Inter Branch Adjustment Account */
export const RAL_INTER_BRANCH_AGEING: RalAgeingRow[] = [
{ period: '3 months to 6 months', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
{ period: 'Above 6 months to 1 year', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
{ period: 'Above 1 year to 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
{ period: 'Above 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
{
period: '3 months to 6 months',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 6 months to 1 year',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 1 year to 2 years',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 2 years',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{ period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
];
/** Sheet 3 — Annexure 1 :: 4. Age-wise Inter Bank Reconciliation Account */
export const RAL_INTER_BANK_AGEING: RalAgeingRow[] = [
{ period: '3 months to 6 months', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
{ period: 'Above 6 months to 1 year', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
{ period: 'Above 1 year to 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
{ period: 'Above 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
{
period: '3 months to 6 months',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 6 months to 1 year',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 1 year to 2 years',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 2 years',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{ period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
];
/** Sheet 3 — Annexure 1 :: 5. Unsecured guarantees issued by the bank */
export const RAL_UNSECURED_GUARANTEES: RalUnsecuredGuarantee[] = [
{ srNo: 1, borrower: '', beneficiary: '', amountOutstanding: null }
];
{ srNo: 1, borrower: '', beneficiary: '', amountOutstanding: null }];
export const RAL_UNSECURED_GUARANTEES_TOTAL = 0;
/** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */
@@ -401,20 +452,49 @@ export const RAL_SUNDRY_DEBTORS_NOTE =
export const RAL_DEPOSITORS_BORROWERS: RalLine[] = [
{ label: '1.Total No of Depositors', level: 0, value: 8156, total: true, count: true },
{ label: 'Total Customer Deposits', level: 0, value: 634309, total: true },
{ label: 'a. i) Of which Total No. of Depositors with Deposits less than Rs. 1 Lakh', level: 1, value: 6568, count: true },
{
label: 'a. i) Of which Total No. of Depositors with Deposits less than Rs. 1 Lakh',
level: 1,
value: 6568,
count: true
},
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 109992 },
{ label: 'b. i) Of which Total No. of Depositors with Deposits Between Rs 1 Lakh to Rs. 2 Lakh', level: 1, value: 739, count: true },
{
label: 'b. i) Of which Total No. of Depositors with Deposits Between Rs 1 Lakh to Rs. 2 Lakh',
level: 1,
value: 739,
count: true
},
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 102475 },
{ label: 'c. i) Of which Total No. of Depositors with Deposits Between Rs. 2 Lakh to Rs 5 Lakh', level: 1, value: 580, count: true },
{
label: 'c. i) Of which Total No. of Depositors with Deposits Between Rs. 2 Lakh to Rs 5 Lakh',
level: 1,
value: 580,
count: true
},
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 176820 },
{ label: '2.Total No of Borrowers', level: 0, value: 363, total: true, count: true },
{ label: 'Total Loans and Advances (Other than Banks)', level: 0, value: 391288, total: true },
{ label: 'a. i) Of which Total No. of Borrowers with Loans and Advances less than Rs. 1 Lakh', level: 1, value: 55, count: true },
{
label: 'a. i) Of which Total No. of Borrowers with Loans and Advances less than Rs. 1 Lakh',
level: 1,
value: 55,
count: true
},
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 2061 },
{ label: 'b. i) Of which Total No. of Borrowers with Loans and Advances Between Rs 1 Lakh to Rs. 2 Lakh', level: 1, value: 18, count: true },
{
label: 'b. i) Of which Total No. of Borrowers with Loans and Advances Between Rs 1 Lakh to Rs. 2 Lakh',
level: 1,
value: 18,
count: true
},
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 2837 },
{ label: 'c. i) Of which Total No. of Borrowers with Loans and Advances Between Rs. 2 Lakh to Rs 5 Lakh', level: 1, value: 55, count: true },
{
label: 'c. i) Of which Total No. of Borrowers with Loans and Advances Between Rs. 2 Lakh to Rs 5 Lakh',
level: 1,
value: 55,
count: true
},
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 21260 }
];
@@ -81,11 +81,16 @@
<div class="oss-section">
<div class="oss-sheet-banner">Assets &amp; Liabilities</div>
<ng-container [ngTemplateOutlet]="glBar"></ng-container>
<div class="oss-tbl-wrap">
<table class="oss-tbl">
<thead>
<tr>
<th>Liabilities</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
<th class="ta-r">Rs in Thousands</th>
</tr>
</thead>
@@ -93,8 +98,25 @@
@for (line of liabilities; track $index) {
<tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total">
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
@if (glMapMode) {
<td>
<ng-container
[ngTemplateOutlet]="glCell"
[ngTemplateOutletContext]="{ label: line.label, filed: line.value }"
></ng-container>
</td>
}
<td
class="ta-r mono"
[class.derived]="line.total"
[class.gl-sourced]="isGlMapped(line.label)"
[class.gl-diff]="glDiffers(line.label, line.value)"
>
{{
effectiveValue(line.label, line.value) === null
? ''
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
}}
</td>
</tr>
}
@@ -107,6 +129,9 @@
<thead>
<tr>
<th>Assets</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
<th class="ta-r">Rs in Thousands</th>
</tr>
</thead>
@@ -114,8 +139,25 @@
@for (line of assets; track $index) {
<tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total">
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
@if (glMapMode) {
<td>
<ng-container
[ngTemplateOutlet]="glCell"
[ngTemplateOutletContext]="{ label: line.label, filed: line.value }"
></ng-container>
</td>
}
<td
class="ta-r mono"
[class.derived]="line.total"
[class.gl-sourced]="isGlMapped(line.label)"
[class.gl-diff]="glDiffers(line.label, line.value)"
>
{{
effectiveValue(line.label, line.value) === null
? ''
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
}}
</td>
</tr>
}
@@ -343,7 +385,7 @@
<tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total">
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
{{ line.value === null ? '' : (line.value | number: (line.count ? '1.0-0' : '1.2-2')) }}
</td>
</tr>
}
@@ -417,6 +459,68 @@
</div>
}
<!-- GL mapping toolbar, shown above any mappable statement -->
<ng-template #glBar>
<div class="oss-gl-bar" [class.on]="glMapMode">
<div class="oss-gl-bar-l">
<fa-icon icon="project-diagram"></fa-icon>
<span>
<strong>General Ledger mapping</strong>
@if (glMappedCount) {
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
} @else {
<span class="muted"> — pin GL accounts to a line and its figure comes from the ledger</span>
}
</span>
</div>
<div class="oss-gl-bar-r">
@if (glMapMode) {
@if (canSelectGlBranch) {
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
<option value="">All branches</option>
@for (b of glBranches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
} @else {
<span class="oss-gl-scope" title="Your login is restricted to this branch">
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
</span>
}
@if (glMappedCount) {
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
}
}
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
</button>
</div>
</div>
</ng-template>
<!-- Per-line GL chips and picker trigger -->
<ng-template #glCell let-label="label" let-filed="filed">
<div class="oss-gl-cell">
@for (code of glCodesFor(label); track code) {
<span class="oss-gl-chip">
<span class="mono">{{ code }}</span>
<button class="oss-gl-chip-x" (click)="removeGlFromLine(label, code)" [attr.aria-label]="'Remove ' + code">
<fa-icon icon="times"></fa-icon>
</button>
</span>
}
<button class="oss-gl-add" (click)="openGlPicker(label, filed)">
<fa-icon icon="plus"></fa-icon> {{ glCodesFor(label).length ? 'Edit' : 'Add GL' }}
</button>
@if (glDiffers(label, filed)) {
<span class="oss-gl-warn" title="Ledger figure differs from the value filed on the return">
<fa-icon icon="exclamation-triangle"></fa-icon> differs from filed
</span>
}
</div>
</ng-template>
<!-- TOAST -->
<div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import { GlMappingHost } from '../gl-mapping.host';
import {
addBanner,
addLineRows,
@@ -55,7 +56,14 @@ type Oss1Tab = 'general' | 'balance-sheet' | 'annexure1' | 'annexure2' | 'signat
FormsModule
]
})
export class Oss1RalComponent {
export class Oss1RalComponent extends GlMappingHost {
readonly glReturnCode = 'R070';
/** RAL covers the reporting quarter; mapped figures are drawn from the same span. */
get glRange() {
return { from: '2026-04-01', to: '2026-06-30' };
}
activeTab: Oss1Tab = 'general';
generalInfo = RAL_GENERAL_INFO;
@@ -146,8 +154,7 @@ export class Oss1RalComponent {
const sheet = wb.addWorksheet('Assets & Liabilities');
sheet.columns = [
{ width: 78 },
{ width: 20 }
];
{ width: 20 }];
addBanner(sheet, 'Assets & Liabilities', 'B');
const first = sheet.rowCount + 1;
@@ -187,18 +194,25 @@ export class Oss1RalComponent {
{ width: 16 },
{ width: 12 },
{ width: 12 },
{ width: 12 }
];
{ width: 12 }];
addBanner(sheet, 'Annexure 1', 'H');
// 1. Break-up of Other Funds and Reserves
let start = sheet.rowCount + 1;
const fundsTitle = sheet.addRow(['1. Break-up of Other Funds and Reserves', '', 'Rs in Thousands']);
const fundsTitle = sheet.addRow([
'1. Break-up of Other Funds and Reserves',
'',
'Rs in Thousands'
]);
sheet.mergeCells(`A${fundsTitle.number}:B${fundsTitle.number}`);
fundsTitle.font = { bold: true };
fundsTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const line of this.otherFunds) {
const row = sheet.addRow([line.label, '', line.value]);
const row = sheet.addRow([
line.label,
'',
line.value
]);
sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(3).numFmt = '#,##0.00';
@@ -281,10 +295,20 @@ export class Oss1RalComponent {
gHead.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
gHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const g of this.unsecuredGuarantees) {
const row = sheet.addRow([g.srNo, g.borrower, g.beneficiary, g.amountOutstanding]);
const row = sheet.addRow([
g.srNo,
g.borrower,
g.beneficiary,
g.amountOutstanding
]);
row.getCell(4).numFmt = '#,##0.00';
}
const gTotal = sheet.addRow(['Total', '', '', this.unsecuredGuaranteesTotal]);
const gTotal = sheet.addRow([
'Total',
'',
'',
this.unsecuredGuaranteesTotal
]);
gTotal.font = { bold: true };
gTotal.getCell(4).numFmt = '#,##0.00';
gTotal.getCell(4).fill = solidFill(XL.derivedBlue);
@@ -327,9 +351,7 @@ export class Oss1RalComponent {
3,
4,
5
].forEach(
(c) => (row.getCell(c).fill = solidFill(XL.derivedBlue))
);
].forEach((c) => (row.getCell(c).fill = solidFill(XL.derivedBlue)));
}
}
boxAllCells(sheet, start, sheet.rowCount, 5);
@@ -341,8 +363,7 @@ export class Oss1RalComponent {
{ width: 10 },
{ width: 62 },
{ width: 20 },
{ width: 20 }
];
{ width: 20 }];
addBanner(sheet, 'Annexure 2', 'D');
for (const block of this.sundryDebtors) {
@@ -357,13 +378,23 @@ export class Oss1RalComponent {
s.font = { bold: true, size: 10 };
s.getCell(1).fill = solidFill(XL.headerGrey);
const head = sheet.addRow(['Sr. No', 'Due from Name', 'Amount due', 'Provision made']);
const head = sheet.addRow([
'Sr. No',
'Due from Name',
'Amount due',
'Provision made'
]);
head.font = { bold: true };
head.alignment = { horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const r of block.rows) {
const row = sheet.addRow([r.srNo, r.dueFromName, r.amountDue, r.provisionMade]);
const row = sheet.addRow([
r.srNo,
r.dueFromName,
r.amountDue,
r.provisionMade
]);
row.getCell(3).numFmt = '#,##0.00';
row.getCell(4).numFmt = '#,##0.00';
}
@@ -378,14 +409,22 @@ export class Oss1RalComponent {
// Details of Depositors & Borrowers
let start = sheet.rowCount + 1;
const dbTitle = sheet.addRow(['Details of Depositors & Borrowers', '', 'Rs. In Thousands']);
const dbTitle = sheet.addRow([
'Details of Depositors & Borrowers',
'',
'Rs. In Thousands'
]);
sheet.mergeCells(`A${dbTitle.number}:B${dbTitle.number}`);
dbTitle.font = { bold: true };
dbTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
dbTitle.getCell(3).alignment = { horizontal: 'right' };
for (const line of this.depositorsBorrowers) {
const row = sheet.addRow([line.label, '', line.value]);
const row = sheet.addRow([
line.label,
'',
line.value
]);
sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle', indent: line.level };
row.getCell(3).numFmt = line.count ? '#,##0' : '#,##0.00';
@@ -404,7 +443,11 @@ export class Oss1RalComponent {
cTitle.font = { bold: true };
cTitle.getCell(1).fill = solidFill(XL.headerGrey);
for (const line of this.complaints) {
const row = sheet.addRow([line.label, '', line.value]);
const row = sheet.addRow([
line.label,
'',
line.value
]);
sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(3).numFmt = '#,##0';
@@ -122,11 +122,52 @@
</table>
</div>
<!-- GL mapping toolbar -->
<div class="oss-gl-bar" [class.on]="glMapMode">
<div class="oss-gl-bar-l">
<fa-icon icon="project-diagram"></fa-icon>
<span>
<strong>General Ledger mapping</strong>
@if (glMappedCount) {
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
} @else {
<span class="muted"> — pin GL accounts to a line and its figure comes from the ledger</span>
}
</span>
</div>
<div class="oss-gl-bar-r">
@if (glMapMode) {
@if (canSelectGlBranch) {
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
<option value="">All branches</option>
@for (b of glBranches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
} @else {
<span class="oss-gl-scope" title="Your login is restricted to this branch">
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
</span>
}
@if (glMappedCount) {
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
}
}
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
</button>
</div>
</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl">
<thead>
<tr>
<th>Particulars</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
<th class="ta-r">Rs in Thousand</th>
</tr>
</thead>
@@ -134,8 +175,44 @@
@for (line of earnings; track $index) {
<tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total">
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
@if (glMapMode) {
<td>
<div class="oss-gl-cell">
@for (code of glCodesFor(line.label); track code) {
<span class="oss-gl-chip">
<span class="mono">{{ code }}</span>
<button
class="oss-gl-chip-x"
(click)="removeGlFromLine(line.label, code)"
[attr.aria-label]="'Remove ' + code"
>
<fa-icon icon="times"></fa-icon>
</button>
</span>
}
<button class="oss-gl-add" (click)="openGlPicker(line.label, line.value)">
<fa-icon icon="plus"></fa-icon>
{{ glCodesFor(line.label).length ? 'Edit' : 'Add GL' }}
</button>
@if (glDiffers(line.label, line.value)) {
<span class="oss-gl-warn" title="Ledger figure differs from the value filed on the return">
<fa-icon icon="exclamation-triangle"></fa-icon> differs from filed
</span>
}
</div>
</td>
}
<td
class="ta-r mono"
[class.derived]="line.total"
[class.gl-sourced]="isGlMapped(line.label)"
[class.gl-diff]="glDiffers(line.label, line.value)"
>
{{
effectiveValue(line.label, line.value) === null
? ''
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
}}
</td>
</tr>
}
@@ -186,9 +263,10 @@
<div class="oss-balance" [class.oss-balance--bad]="!otherExpensesTally">
<fa-icon [icon]="otherExpensesTally ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span>
Detail total <strong>&#8377;{{ otherExpensesTotal | number: '1.2-2' }}</strong> vs Earnings line 12 ix)
Others
<strong>&#8377;{{ otherExpensesLineValue === null ? '—' : (otherExpensesLineValue | number: '1.2-2') }}</strong>
Detail total <strong>&#8377;{{ otherExpensesTotal | number: '1.2-2' }}</strong> vs Earnings line 12 ix) Others
<strong
>&#8377;{{ otherExpensesLineValue === null ? '—' : (otherExpensesLineValue | number: '1.2-2') }}</strong
>
— {{ otherExpensesTally ? 'tallies' : 'MISMATCH — resolve before filing' }}
</span>
</div>
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import { GlMappingHost } from '../gl-mapping.host';
import {
addBanner,
addLineRows,
@@ -54,7 +55,14 @@ interface TieUp {
FormsModule
]
})
export class Oss2RoeComponent {
export class Oss2RoeComponent extends GlMappingHost {
readonly glReturnCode = 'R071';
/** RoE reports the quarter; mapped income and expense figures use the same span. */
get glRange() {
return { from: '2026-04-01', to: '2026-06-30' };
}
activeTab: Oss2Tab = 'general';
generalInfo = ROE_GENERAL_INFO;
@@ -178,8 +186,7 @@ export class Oss2RoeComponent {
const sheet = wb.addWorksheet('Earnings');
sheet.columns = [
{ width: 90 },
{ width: 20 }
];
{ width: 20 }];
addBanner(sheet, 'Earnings', 'B');
const first = sheet.rowCount + 1;
@@ -205,23 +212,31 @@ export class Oss2RoeComponent {
const sheet = wb.addWorksheet('Interest Receivable Not Recog');
sheet.columns = [
{ width: 72 },
{ width: 22 }
];
{ width: 22 }];
addBanner(sheet, 'Interest Receivable Not Recognized', 'B');
const start = sheet.rowCount + 1;
const unit = sheet.addRow(['', 'Rs in Thousand']);
const unit = sheet.addRow([
'',
'Rs in Thousand'
]);
unit.font = { bold: true };
unit.alignment = { horizontal: 'center' };
unit.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
const head = sheet.addRow(['Interest receivable on NPAs not recognized as income', this.quarterEndDate]);
const head = sheet.addRow([
'Interest receivable on NPAs not recognized as income',
this.quarterEndDate
]);
head.font = { bold: true };
head.getCell(2).alignment = { horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const r of this.interestNotRecognized) {
const row = sheet.addRow([r.label, r.value]);
const row = sheet.addRow([
r.label,
r.value
]);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(1).fill = solidFill(XL.headerGrey);
row.getCell(2).numFmt = '#,##0.00';
@@ -238,28 +253,43 @@ export class Oss2RoeComponent {
sheet.columns = [
{ width: 10 },
{ width: 56 },
{ width: 20 }
];
{ width: 20 }];
addBanner(sheet, 'Other Operating Expenses', 'C');
const start = sheet.rowCount + 1;
const title = sheet.addRow(['Details of Other Operating Expenses', '', 'Rs in Thousand']);
const title = sheet.addRow([
'Details of Other Operating Expenses',
'',
'Rs in Thousand'
]);
sheet.mergeCells(`A${title.number}:B${title.number}`);
title.font = { bold: true };
title.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
title.getCell(3).alignment = { horizontal: 'center' };
const head = sheet.addRow(['Sr No', 'Particulars', 'Amount']);
const head = sheet.addRow([
'Sr No',
'Particulars',
'Amount'
]);
head.font = { bold: true };
head.alignment = { horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const e of this.otherExpenses) {
const row = sheet.addRow([e.srNo, e.particulars, e.amount]);
const row = sheet.addRow([
e.srNo,
e.particulars,
e.amount
]);
row.getCell(3).numFmt = '#,##0.00';
}
const total = sheet.addRow(['Total', '', this.otherExpensesTotal]);
const total = sheet.addRow([
'Total',
'',
this.otherExpensesTotal
]);
sheet.mergeCells(`A${total.number}:B${total.number}`);
total.font = { bold: true };
total.getCell(1).fill = solidFill(XL.headerGrey);
@@ -268,7 +298,11 @@ export class Oss2RoeComponent {
boxAllCells(sheet, start, sheet.rowCount, 3);
const ref = sheet.addRow(['', '', 'Refer Earnings Sheet — line 12 ix) Others']);
const ref = sheet.addRow([
'',
'',
'Refer Earnings Sheet — line 12 ix) Others'
]);
ref.getCell(3).font = { italic: true, size: 10 };
}
File diff suppressed because it is too large Load Diff
@@ -52,6 +52,46 @@
}
</div>
<!-- GL mapping toolbar (applies to the first figure column of each statement) -->
@if (activeTab !== 'filing' && activeTab !== 'signatory') {
<div class="oss-gl-bar" [class.on]="glMapMode">
<div class="oss-gl-bar-l">
<fa-icon icon="project-diagram"></fa-icon>
<span>
<strong>General Ledger mapping</strong>
@if (glMappedCount) {
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
} @else {
<span class="muted"> — pin GL accounts to a line and its first figure comes from the ledger</span>
}
</span>
</div>
<div class="oss-gl-bar-r">
@if (glMapMode) {
@if (canSelectGlBranch) {
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
<option value="">All branches</option>
@for (b of glBranches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
} @else {
<span class="oss-gl-scope" title="Your login is restricted to this branch">
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
</span>
}
@if (glMappedCount) {
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
}
}
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
</button>
</div>
</div>
}
<!-- FILING INFORMATION -->
@if (activeTab === 'filing') {
<div class="oss-section">
@@ -74,7 +114,9 @@
<!-- PORTFOLIO ANALYSIS -->
@if (activeTab === 'portfolio') {
<div class="oss-section">
<div class="oss-sheet-banner">PortfolioAnalysis(loans_advncs) — Portfolio Analysis (By delinquency in Interest Payment)</div>
<div class="oss-sheet-banner">
PortfolioAnalysis(loans_advncs) — Portfolio Analysis (By delinquency in Interest Payment)
</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioAnalysis }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioMemo }"></ng-container>
</div>
@@ -117,7 +159,9 @@
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: capitalMemo }"></ng-container>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs &mdash; Rs in Thousands</div>
<div class="oss-tbl-h">
Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs &mdash; Rs in Thousands
</div>
<table class="oss-tbl">
<thead>
<tr>
@@ -240,7 +284,8 @@
Sectoral gross advances <strong>&#8377;{{ sectoralGross | number: '1.2-2' }}</strong> and NPAs
<strong>&#8377;{{ sectoralNpa | number: '1.2-2' }}</strong> do not match the closing position reported by
Classification (<strong>&#8377;{{ classificationTotal | number: '1.2-2' }}</strong> /
<strong>&#8377;{{ grossNpa | number: '1.2-2' }}</strong>).
<strong>&#8377;{{ grossNpa | number: '1.2-2' }}</strong
>).
@if (sectoralMatchesOpening) {
They match the <em>opening</em> balances in Movement Parts 1 and 2 instead — this statement appears to
have been prepared on the previous quarter's book.
@@ -326,7 +371,10 @@
<ng-template #matrix let-t="t">
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">
{{ t.title }}@if (t.unit) {<span class="oss-tbl-unit">{{ t.unit }}</span>}
{{ t.title }}
@if (t.unit) {
<span class="oss-tbl-unit">{{ t.unit }}</span>
}
</div>
@if (t.subtitle) {
<div class="oss-tbl-sub">{{ t.subtitle }}</div>
@@ -335,6 +383,9 @@
<thead>
<tr>
<th class="oss-col-label">Particulars</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
@for (c of t.columns; track $index) {
<th class="ta-r">{{ c }}</th>
}
@@ -344,12 +395,48 @@
@for (row of t.rows; track $index) {
<tr [class.total-row]="row.total" [class.section-row]="row.header">
<td [class]="'lvl-' + row.level">{{ row.label }}</td>
@if (glMapMode) {
<td>
@if (!row.header) {
<div class="oss-gl-cell">
@for (code of glCodesFor(t.title + ' / ' + row.label); track code) {
<span class="oss-gl-chip">
<span class="mono">{{ code }}</span>
<button
class="oss-gl-chip-x"
(click)="removeGlFromLine(t.title + ' / ' + row.label, code)"
[attr.aria-label]="'Remove ' + code"
>
<fa-icon icon="times"></fa-icon>
</button>
</span>
}
<button
class="oss-gl-add"
(click)="openGlPicker(t.title + ' / ' + row.label, row.values[0] ?? null)"
>
<fa-icon icon="plus"></fa-icon>
{{ glCodesFor(t.title + ' / ' + row.label).length ? 'Edit' : 'Add GL' }}
</button>
</div>
}
</td>
}
@if (row.header) {
<td [attr.colspan]="t.columns.length"></td>
} @else {
@for (v of row.values; track $index) {
<td class="ta-r mono" [class.derived]="row.total">
{{ v === null ? '' : (v | number: '1.2-2') }}{{ v !== null && row.percent ? '%' : '' }}
<td
class="ta-r mono"
[class.derived]="row.total"
[class.gl-sourced]="$index === 0 && isGlMapped(t.title + ' / ' + row.label)"
[class.gl-diff]="$index === 0 && glDiffers(t.title + ' / ' + row.label, v)"
>
@if ($index === 0 && isGlMapped(t.title + ' / ' + row.label)) {
{{ effectiveValue(t.title + ' / ' + row.label, v) | number: '1.2-2' }}{{ row.percent ? '%' : '' }}
} @else {
{{ v === null ? '' : (v | number: '1.2-2') }}{{ v !== null && row.percent ? '%' : '' }}
}
</td>
}
}
@@ -12,6 +12,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import { MatrixTable } from '../oss-return.model';
import { GlMappingHost } from '../gl-mapping.host';
import {
addBanner,
addMatrixTable,
@@ -77,7 +78,14 @@ type Oss3Tab =
FormsModule
]
})
export class Oss3RaqComponent {
export class Oss3RaqComponent extends GlMappingHost {
readonly glReturnCode = 'R323';
/** RAQ reports the quarter; mapped figures are drawn from the same span. */
get glRange() {
return { from: '2026-04-01', to: '2026-06-30' };
}
activeTab: Oss3Tab = 'filing';
/** Tab strip definition — keeps the template free of a dozen near-identical buttons. */
@@ -313,8 +321,7 @@ export class Oss3RaqComponent {
{ width: 16 },
{ width: 18 },
{ width: 18 },
{ width: 18 }
];
{ width: 18 }];
addBanner(sheet, 'TopImpairedCredits - Top Impaired Credits', 'I');
addMatrixTable(sheet, this.capitalMemo);
for (const t of this.recoveryTables) {
@@ -398,8 +405,7 @@ export class Oss3RaqComponent {
{ width: 18 },
{ width: 20 },
{ width: 22 },
{ width: 20 }
];
{ width: 20 }];
addBanner(sheet, 'ExposureLimits - Single and Group Borrower Exposure Limit', 'G');
addMatrixTable(sheet, this.capitalPreviousMarch);
+10
View File
@@ -43,6 +43,16 @@ const routes: Routes = [
data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' },
component: SanctionsLogsComponent
},
// Must sit above ':filter', which would otherwise match this path and
// render an empty report category instead.
{
path: 'form-97-98',
data: { title: 'Form 97 / 98 Transaction Report', breadcrumb: 'Form 97 / 98 Transaction Report' },
loadComponent: () =>
import('../form-declarations/form-declaration-report.component').then(
(m) => m.FormDeclarationReportComponent
)
},
{
path: ':filter',
data: { routeParamBreadcrumb: 'filter' },
+2
View File
@@ -24,6 +24,7 @@ import {
faCalendar,
faCalendarAlt,
faCaretDown,
faCaretRight,
faChartBar,
faCheck,
faCheckCircle,
@@ -261,6 +262,7 @@ export class IconsModule {
faCalendarAlt,
faCalendarCheck,
faCaretDown,
faCaretRight,
faChartBar,
faCheck,
faCheckCircle,
+1 -1
View File
@@ -10,7 +10,7 @@
export default {
'mifos_x': {
'version': '260801',
'hash': '1c125d97'
'hash': '2a9df828'
},
'allow_switching_backend_instance': true
};