oss file updated, profitl loss as per the day month and also with date select

This commit is contained in:
2026-08-01 11:46:11 +05:30
parent 4261f9659e
commit bd753f4983
33 changed files with 8748 additions and 404 deletions
+2
View File
@@ -64,6 +64,7 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module'; import { DeafModule } from './deaf/deaf.module';
import { DicgcModule } from './dicgc/dicgc.module'; import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module'; import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
import { SftModule } from './sft/sft.module'; import { SftModule } from './sft/sft.module';
import { InvestmentModule } from './investment/investment.module'; import { InvestmentModule } from './investment/investment.module';
import { NpaModule } from './npa/npa.module'; import { NpaModule } from './npa/npa.module';
@@ -173,6 +174,7 @@ export function HttpLoaderFactory(http: HttpClient) {
DeafModule, DeafModule,
DicgcModule, DicgcModule,
FiuModule, FiuModule,
GlReportsModule,
SftModule, SftModule,
InvestmentModule, InvestmentModule,
NpaModule, NpaModule,
@@ -2236,26 +2236,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
} }
// Match monthly income ranges // Match monthly income ranges
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) { if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
continue; continue;
} }
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) { if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
continue; continue;
} }
// Match annual income ranges (evaluated against totalAnnualIncome) // Match annual income ranges (evaluated against totalAnnualIncome)
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) { if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
continue; continue;
} }
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) { if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
continue; continue;
} }
// Match turnover ranges // Match turnover ranges
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) { if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
continue; continue;
} }
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) { if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
continue; continue;
} }
+23 -41
View File
@@ -828,25 +828,29 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Trial Balance')" [class.hidden]="!matchesGlSearch('Trial Balance')"
(click)="glMenuOpen = false; openAccountingReportsDialog('GL-DR-CR')" routerLink="/gl/reports/trial-balance"
(click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Trial Balance</a ><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Trial Balance</a
> >
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Profit and Loss')" [class.hidden]="!matchesGlSearch('Profit and Loss')"
(click)="glMenuOpen = false; openAccountingReportsDialog('PNL')" routerLink="/gl/reports/profit-and-loss"
(click)="glMenuOpen = false"
><fa-icon icon="chart-line" class="gl-icon" style="color: #b45309"></fa-icon> Profit and Loss</a ><fa-icon icon="chart-line" class="gl-icon" style="color: #b45309"></fa-icon> Profit and Loss</a
> >
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')" [class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
(click)="glMenuOpen = false; openAccountingReportsDialog('BALANCE-SHEET')" routerLink="/gl/reports/balance-sheet"
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidate Balance Sheet</a (click)="glMenuOpen = false"
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidated Balance Sheet</a
> >
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Comparative Balance Sheet')" [class.hidden]="!matchesGlSearch('Comparative Balance Sheet')"
(click)="glMenuOpen = false; openAccountingReportsDialog('COMPARATIVE-BS')" routerLink="/gl/reports/comparative-balance-sheet"
(click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Comparative Balance ><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Comparative Balance
Sheet</a Sheet</a
> >
@@ -871,7 +875,8 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('GL Wise DR CR')" [class.hidden]="!matchesGlSearch('GL Wise DR CR')"
(click)="glMenuOpen = false; openAccountingReportsDialog('GL-DR-CR')" routerLink="/gl/reports/gl-wise-dr-cr"
(click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR ><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR
Report</a Report</a
> >
@@ -886,10 +891,11 @@
> >
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Contingent Expense')" [class.hidden]="!matchesGlSearch('Contingent Liabilities')"
(click)="glMenuOpen = false; openAccountingReportsDialog('CONTINGENT-LIAB')" routerLink="/gl/reports/contingent-liabilities"
(click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent ><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
Expense</a Liabilities</a
> >
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false" <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 ><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</a
@@ -906,7 +912,8 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Misc Expense Ledger')" [class.hidden]="!matchesGlSearch('Misc Expense Ledger')"
(click)="glMenuOpen = false; openAccountingReportsDialog('MISC-EXP')" routerLink="/gl/reports/misc-expense-ledger"
(click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a ><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a
> >
</div> </div>
@@ -1706,52 +1713,27 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')" [class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('RAL')" routerLink="/rbi/oss1"
(click)="rbiMenuOpen = false"
> >
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 1 XBRL/CIMS <fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 1 XBRL/CIMS
</a> </a>
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 2 XBRL')" [class.hidden]="!matchesRbiSearch('OSS 2 XBRL')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-2')" routerLink="/rbi/oss2"
(click)="rbiMenuOpen = false"
> >
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL <fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL
</a> </a>
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')" [class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-2')" routerLink="/rbi/oss3"
(click)="rbiMenuOpen = false"
> >
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS <fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
</a> </a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 4')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-4')"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 4
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 5')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-5')"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 5
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 6')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-6')"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 6
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 7')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-7')"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 7
</a>
</div> </div>
<!-- CIMS / XBRL Returns --> <!-- CIMS / XBRL Returns -->
<div class="gl-group"> <div class="gl-group">
@@ -365,6 +365,21 @@
</mat-form-field> </mat-form-field>
</form> </form>
} }
<!-- Compliance / AML Action Triage -->
<div class="compliance-triage" style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px;">
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px;">
Compliance & AML Triage
</h4>
<div style="display: flex; flex-direction: column; gap: 8px;">
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{standalone: true}">
Flag as suspicious (escalate to STR cases under RBI compliance)
</mat-checkbox>
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{standalone: true}">
Mark as whitelisted (prevent future AML alerts for this account)
</mat-checkbox>
</div>
</div>
} }
<div #lightboxRoot style="display: none"></div> <div #lightboxRoot style="display: none"></div>
@@ -131,6 +131,90 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
/** 'any' = mode of operation lets any one signer sign (Either/Anyone or Survivor); 'all' = every mandatory signer must sign. */ /** 'any' = mode of operation lets any one signer sign (Either/Anyone or Survivor); 'all' = every mandatory signer must sign. */
signerSelectionMode: 'any' | 'all' = 'any'; signerSelectionMode: 'any' | 'all' = 'any';
manualOverrideChecked = false; manualOverrideChecked = false;
flagAsSuspicious = false;
markAsWhitelisted = false;
handleComplianceActions(): void {
if (this.flagAsSuspicious) {
const existingStrData = localStorage.getItem('fiu_str_cases');
let cases: any[] = [];
if (existingStrData) {
try {
cases = JSON.parse(existingStrData);
} catch (e) {
cases = [];
}
}
const newRef = 101 + cases.length;
const newCase: any = {
reportType: 'STR',
reportRefNo: newRef,
branchCode: this.item.presentingBranchCode || '1',
accountNumber: this.item.accountNo || this.item.accountId || '110001101000838',
txnBranchCode: this.item.presentingBranchCode || '1',
depositOrWithdrawal: 'Withdrawal',
txnId: this.item.id,
txnDate: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
txnTime: new Date().toLocaleTimeString('en-US', { hour12: false }),
txnAmount: this.item.amount,
instrumentType: 'Cheque',
instrumentId: this.item.chequeNumber,
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: this.item.clientName || 'UNKNOWN CLIENT',
detectedOn: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
flaggedBy: 'CTS Clearing Officer',
riskCategory: 'unusual-pattern',
narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`,
status: 'po-review'
};
cases.unshift(newCase);
localStorage.setItem('fiu_str_cases', JSON.stringify(cases));
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
try {
const ctrList = JSON.parse(storedCtr);
const matching = ctrList.find((t: any) => t.accountNumber === newCase.accountNumber);
if (matching) {
matching.complianceStatus = 'SUSPICIOUS';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList));
}
} catch (e) {}
}
} else if (this.markAsWhitelisted) {
const existingWhitelist = localStorage.getItem('compliance_whitelisted_accounts');
let whitelisted: string[] = [];
if (existingWhitelist) {
try {
whitelisted = JSON.parse(existingWhitelist);
} catch (e) {
whitelisted = [];
}
}
const account = this.item.accountNo || this.item.accountId;
if (account && !whitelisted.includes(account.toString())) {
whitelisted.push(account.toString());
localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted));
}
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
try {
const ctrList = JSON.parse(storedCtr);
const matching = ctrList.find((t: any) => t.accountNumber === (account ? account.toString() : ''));
if (matching) {
matching.complianceStatus = 'WHITELISTED';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList));
}
} catch (e) {}
}
}
}
/** Every automatic validation rule InwardClearingValidationService already computes, shown so the clerk sees WHY, not just a decision. */ /** Every automatic validation rule InwardClearingValidationService already computes, shown so the clerk sees WHY, not just a decision. */
readonly validationRules: { key: string; label: string; goodWhenTrue: boolean }[] = [ readonly validationRules: { key: string; label: string; goodWhenTrue: boolean }[] = [
@@ -652,6 +736,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
this.ctsService.passInwardItem(this.item.id, payload).subscribe({ this.ctsService.passInwardItem(this.item.id, payload).subscribe({
next: () => { next: () => {
this.busy = false; this.busy = false;
this.handleComplianceActions();
this.notify('Cheque passed'); this.notify('Cheque passed');
this.dialogRef.close({ actioned: true }); this.dialogRef.close({ actioned: true });
}, },
@@ -676,6 +761,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
this.ctsService.returnInwardItem(this.item.id, payload).subscribe({ this.ctsService.returnInwardItem(this.item.id, payload).subscribe({
next: () => { next: () => {
this.busy = false; this.busy = false;
this.handleComplianceActions();
this.notify('Cheque returned'); this.notify('Cheque returned');
this.dialogRef.close({ actioned: true }); this.dialogRef.close({ actioned: true });
}, },
+376 -299
View File
@@ -42,8 +42,12 @@ export interface CtrTransaction {
declaration: string; declaration: string;
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */ /** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
customerName?: string; customerName?: string;
complianceStatus?: 'PENDING' | 'WHITELISTED' | 'SUSPICIOUS';
} }
/** Outcome of the Principal Officer's investigation into an STR case */
export type PoFinding = 'CLEAN' | 'SUSPICIOUS';
/** STR record — workflow extension on top of TC2 */ /** STR record — workflow extension on top of TC2 */
export interface StrTransaction extends CtrTransaction { export interface StrTransaction extends CtrTransaction {
detectedOn: string; detectedOn: string;
@@ -58,6 +62,8 @@ export interface StrTransaction extends CtrTransaction {
narrative: string; narrative: string;
status: StrStatus; status: StrStatus;
poRemarks?: string; poRemarks?: string;
/** Set once the Principal Officer concludes the investigation */
poFinding?: PoFinding;
} }
/** KC1 — Individual customer card (subset of the 70+ fields, only what UI shows) */ /** KC1 — Individual customer card (subset of the 70+ fields, only what UI shows) */
@@ -127,7 +133,12 @@ export const FIU_BANK = {
cbwtrThreshold: 500000 // CBWTR threshold ₹5 L cbwtrThreshold: 500000 // CBWTR threshold ₹5 L
}; };
/** ── Sample CTR transactions (FINnet TC2-style) ── */ /**
* ── CTR transactions (FINnet TC2-style) ──
* Only CASH transactions that breach the RBI/PMLA reporting limit of ₹10,00,000
* (single txn, or connected transactions aggregating to ₹10 L in a calendar month)
* belong here. Nothing below the threshold and no non-cash instruments.
*/
export const CTR_TXNS: CtrTransaction[] = [ export const CTR_TXNS: CtrTransaction[] = [
{ {
reportType: 'CTR', reportType: 'CTR',
@@ -135,423 +146,489 @@ export const CTR_TXNS: CtrTransaction[] = [
branchCode: '1', branchCode: '1',
accountNumber: '110001101000838', accountNumber: '110001101000838',
txnBranchCode: '1', txnBranchCode: '1',
depositOrWithdrawal: 'Withdrawal', depositOrWithdrawal: 'Deposit',
txnId: 1, txnId: 1,
txnDate: '20-01-2026', txnDate: '05-01-2026',
txnTime: '16:51:28', txnTime: '10:22:41',
txnAmount: 1500000, txnAmount: 1450000,
instrumentType: 'Cheque', instrumentType: 'Cash',
instrumentId: '24304', instrumentId: 'CASH',
nonCustomerRef: '', nonCustomerRef: '',
relationshipFlag: 'Customer', relationshipFlag: 'Customer',
thirdPartyPan: '', thirdPartyPan: '',
declaration: 'NA', declaration: 'NA',
customerName: 'RAVI KUMAR SHARMA' customerName: 'ANAND KRISHNAN'
}, },
{ {
reportType: 'CTR', reportType: 'CTR',
reportRefNo: 1, reportRefNo: 2,
branchCode: '1', branchCode: '3',
accountNumber: '110001101000838', accountNumber: '110001204000712',
txnBranchCode: '1', txnBranchCode: '3',
depositOrWithdrawal: 'Withdrawal', depositOrWithdrawal: 'Deposit',
txnId: 2, txnId: 2,
txnDate: '27-01-2026', txnDate: '09-01-2026',
txnTime: '16:14:22', txnTime: '12:47:09',
txnAmount: 932000, txnAmount: 3200000,
instrumentType: 'Cheque', instrumentType: 'Cash',
instrumentId: '24305', instrumentId: 'CASH',
nonCustomerRef: '', nonCustomerRef: '',
relationshipFlag: 'Customer', relationshipFlag: 'Customer',
thirdPartyPan: '', thirdPartyPan: '',
declaration: 'NA', declaration: 'NA',
customerName: 'RAVI KUMAR SHARMA' customerName: 'MEERA SUNDARAM'
},
{
reportType: 'CTR',
reportRefNo: 2,
branchCode: '1',
accountNumber: '110001352000357',
txnBranchCode: '1',
depositOrWithdrawal: 'Withdrawal',
txnId: 3,
txnDate: '02-01-2026',
txnTime: '15:05:36',
txnAmount: 600000,
instrumentType: 'Cheque',
instrumentId: '100039',
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: 'PRIYA PATEL'
},
{
reportType: 'CTR',
reportRefNo: 2,
branchCode: '1',
accountNumber: '110001352000357',
txnBranchCode: '1',
depositOrWithdrawal: 'Withdrawal',
txnId: 4,
txnDate: '07-01-2026',
txnTime: '14:14:17',
txnAmount: 700000,
instrumentType: 'Cheque',
instrumentId: '100040',
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: 'PRIYA PATEL'
}, },
{ {
reportType: 'CTR', reportType: 'CTR',
reportRefNo: 3, reportRefNo: 3,
branchCode: '7', branchCode: '6',
accountNumber: '110001352000420', accountNumber: '110001307000455',
txnBranchCode: '7', txnBranchCode: '6',
depositOrWithdrawal: 'Deposit', depositOrWithdrawal: 'Withdrawal',
txnId: 5, txnId: 3,
txnDate: '12-01-2026', txnDate: '13-01-2026',
txnTime: '11:28:50', txnTime: '15:03:55',
txnAmount: 1200000, txnAmount: 1075000,
instrumentType: 'Cash', instrumentType: 'Cash',
instrumentId: 'CASH', instrumentId: 'CASH',
nonCustomerRef: '', nonCustomerRef: '',
relationshipFlag: 'Customer', relationshipFlag: 'Customer',
thirdPartyPan: '', thirdPartyPan: '',
declaration: 'NA', declaration: 'NA',
customerName: 'VIKRAM REDDY' customerName: 'HARPREET SINGH BEDI'
}, },
{ {
reportType: 'CTR', reportType: 'CTR',
reportRefNo: 4, reportRefNo: 4,
branchCode: '4', branchCode: '2',
accountNumber: '110001352000511', accountNumber: '110001408000933',
txnBranchCode: '4', txnBranchCode: '2',
depositOrWithdrawal: 'Deposit', depositOrWithdrawal: 'Deposit',
txnId: 6, txnId: 4,
txnDate: '18-01-2026', txnDate: '16-01-2026',
txnTime: '10:14:05', txnTime: '11:19:27',
txnAmount: 2500000, txnAmount: 5600000,
instrumentType: 'Cash', instrumentType: 'Cash',
instrumentId: 'CASH', instrumentId: 'CASH',
nonCustomerRef: '', nonCustomerRef: '',
relationshipFlag: 'Customer', relationshipFlag: 'Customer',
thirdPartyPan: '', thirdPartyPan: '',
declaration: 'NA', declaration: 'NA',
customerName: 'NIRAV SHAH' customerName: 'FAROOQ ABDULLAH KHAN'
}, },
{ {
reportType: 'CTR', reportType: 'CTR',
reportRefNo: 5, reportRefNo: 5,
branchCode: '11', branchCode: '9',
accountNumber: '110001352000604', accountNumber: '110001509000268',
txnBranchCode: '11', txnBranchCode: '9',
depositOrWithdrawal: 'Withdrawal', depositOrWithdrawal: 'Withdrawal',
txnId: 7, txnId: 5,
txnDate: '24-01-2026', txnDate: '20-01-2026',
txnTime: '13:42:11', txnTime: '14:35:12',
txnAmount: 1100000, txnAmount: 2850000,
instrumentType: 'Cheque',
instrumentId: '88123',
nonCustomerRef: 'NCR-001',
relationshipFlag: 'Non-Customer',
thirdPartyPan: 'AKHPV2244R',
declaration: 'NA',
customerName: 'KAVITA VERMA'
}
];
/** ── Sample STR transactions ── */
export const STR_TXNS: StrTransaction[] = [
{
reportType: 'STR',
reportRefNo: 101,
branchCode: '1',
accountNumber: '110001101000841',
txnBranchCode: '1',
depositOrWithdrawal: 'Deposit',
txnId: 8,
txnDate: '15-01-2026',
txnTime: '11:32:00',
txnAmount: 950000,
instrumentType: 'Cash', instrumentType: 'Cash',
instrumentId: 'CASH', instrumentId: 'CASH',
nonCustomerRef: '', nonCustomerRef: '',
relationshipFlag: 'Customer', relationshipFlag: 'Customer',
thirdPartyPan: '', thirdPartyPan: '',
declaration: 'NA', declaration: 'NA',
customerName: 'ARJUN DESHMUKH', customerName: 'DEEPIKA RANGANATHAN'
detectedOn: '16-01-2026', },
flaggedBy: 'AML Engine — Rule R-007', {
riskCategory: 'structuring', reportType: 'CTR',
reportRefNo: 6,
branchCode: '5',
accountNumber: '110001610000841',
txnBranchCode: '5',
depositOrWithdrawal: 'Deposit',
txnId: 6,
txnDate: '23-01-2026',
txnTime: '09:58:33',
txnAmount: 1000000,
instrumentType: 'Cash',
instrumentId: 'CASH',
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: 'RAJENDRA PRASAD YADAV'
},
{
reportType: 'CTR',
reportRefNo: 7,
branchCode: '12',
accountNumber: '110001711000527',
txnBranchCode: '12',
depositOrWithdrawal: 'Deposit',
txnId: 7,
txnDate: '27-01-2026',
txnTime: '16:41:08',
txnAmount: 4300000,
instrumentType: 'Cash',
instrumentId: 'CASH',
nonCustomerRef: 'NCR-014',
relationshipFlag: 'Non-Customer',
thirdPartyPan: 'BXKPT7788M',
declaration: 'NA',
customerName: 'SHABANA TARIQ MIRZA'
}
];
/**
* ── STR cases ──
* Raised either by the AML rules engine or by an officer flagging a CTR row as
* suspicious. Each case is investigated by the Principal Officer, who concludes
* whether the funds are legitimate (CLEAN) or linked to terror financing.
*/
export const STR_TXNS: StrTransaction[] = [
{
reportType: 'STR',
reportRefNo: 101,
branchCode: '8',
accountNumber: '110001812000390',
txnBranchCode: '8',
depositOrWithdrawal: 'Deposit',
txnId: 101,
txnDate: '11-01-2026',
txnTime: '13:08:21',
txnAmount: 2750000,
instrumentType: 'Cash',
instrumentId: 'CASH',
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: 'IMRAN SHEIKH QURESHI',
detectedOn: '12-01-2026',
flaggedBy: 'AML Engine — Rule R-031',
riskCategory: 'sanctions-hit',
narrative: narrative:
'Three sub-₹10L cash deposits within 5 days totalling ₹28.4L; pattern indicative of structuring to avoid CTR threshold.', 'Cash deposit of ₹27.5L followed within 48 hours by outward remittances to three beneficiaries whose names partially match the UAPA Schedule IV designated-persons list. Awaiting Principal Officer investigation.',
status: 'po-review' status: 'po-review'
}, },
{ {
reportType: 'STR', reportType: 'STR',
reportRefNo: 102, reportRefNo: 102,
branchCode: '7', branchCode: '4',
accountNumber: '110001101000922', accountNumber: '110001915000174',
txnBranchCode: '7', txnBranchCode: '4',
depositOrWithdrawal: 'Deposit', depositOrWithdrawal: 'Deposit',
txnId: 9, txnId: 102,
txnDate: '21-01-2026', txnDate: '07-01-2026',
txnTime: '14:18:30', txnTime: '10:44:52',
txnAmount: 4800000, txnAmount: 1980000,
instrumentType: 'NEFT',
instrumentId: 'N1264821',
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: 'LAKSHMI IYER',
detectedOn: '22-01-2026',
flaggedBy: 'Branch Compliance Officer',
riskCategory: 'dormant-revival',
narrative:
'Account dormant for 18 months suddenly received ₹48L NEFT followed by 4 immediate cash withdrawals of ₹2L each.',
status: 'filed',
poRemarks: 'STR filed with FIU-IND. Acknowledgement no: STR-2026-9821.'
},
{
reportType: 'STR',
reportRefNo: 103,
branchCode: '2',
accountNumber: '110001302001012',
txnBranchCode: '2',
depositOrWithdrawal: 'Withdrawal',
txnId: 10,
txnDate: '23-01-2026',
txnTime: '15:42:18',
txnAmount: 1800000,
instrumentType: 'RTGS',
instrumentId: 'R889013',
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: 'SAMEER KAPOOR',
detectedOn: '24-01-2026',
flaggedBy: 'AML Engine — Rule R-014',
riskCategory: 'high-risk-country',
narrative:
'RTGS transfer to beneficiary in jurisdiction listed under FATF grey list (Cayman Islands intermediary). KYC documentation incomplete.',
status: 'draft'
},
{
reportType: 'STR',
reportRefNo: 104,
branchCode: '13',
accountNumber: '110001352001145',
txnBranchCode: '13',
depositOrWithdrawal: 'Deposit',
txnId: 11,
txnDate: '08-01-2026',
txnTime: '12:55:42',
txnAmount: 6500000,
instrumentType: 'Cash', instrumentType: 'Cash',
instrumentId: 'CASH', instrumentId: 'CASH',
nonCustomerRef: '', nonCustomerRef: '',
relationshipFlag: 'Customer', relationshipFlag: 'Customer',
thirdPartyPan: '', thirdPartyPan: '',
declaration: 'NA', declaration: 'NA',
customerName: 'MANMOHAN SINGH GILL', customerName: 'GOPAL VENKATARAMAN',
detectedOn: '09-01-2026', detectedOn: '08-01-2026',
flaggedBy: 'AML Engine — Rule R-022', flaggedBy: 'Branch Compliance Officer',
riskCategory: 'unusual-pattern', riskCategory: 'unusual-pattern',
narrative: narrative:
'Account avg monthly turnover ₹1.5L; received ₹65L cash in single deposit, source-of-funds documentation pending.', 'Declared annual income ₹6L but deposited ₹19.8L cash in a single visit. Customer produced sale deed for ancestral agricultural land.',
status: 'draft',
poFinding: 'CLEAN',
poRemarks:
'Source of funds verified against registered sale deed dated 02-01-2026 and buyer bank statement. Legitimate agricultural land sale — no adverse findings. Case closed, no STR filed with FIU-IND.'
},
{
reportType: 'STR',
reportRefNo: 103,
branchCode: '10',
accountNumber: '110002016000628',
txnBranchCode: '10',
depositOrWithdrawal: 'Withdrawal',
txnId: 103,
txnDate: '19-01-2026',
txnTime: '16:27:40',
txnAmount: 3400000,
instrumentType: 'Cash',
instrumentId: 'CASH',
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: 'ABDUL RAHMAN BAIG',
detectedOn: '20-01-2026',
flaggedBy: 'AML Engine — Rule R-045',
riskCategory: 'high-risk-country',
narrative:
'Account credited by 14 small inward remittances from a FATF high-risk jurisdiction over 9 days, then emptied via ₹34L cash withdrawal at a border-district branch. Classic terror-funding placement-and-dispersal pattern.',
status: 'filed',
poFinding: 'SUSPICIOUS',
poRemarks:
'Investigation confirmed layered inward remittances from a designated high-risk corridor with no commercial rationale. Beneficial owner could not evidence source. STR filed with FIU-IND under PMLA s.12; acknowledgement STR-2026-10457. Account frozen and reported to the Nodal Officer.'
},
{
reportType: 'STR',
reportRefNo: 104,
branchCode: '1',
accountNumber: '110002117000315',
txnBranchCode: '1',
depositOrWithdrawal: 'Deposit',
txnId: 104,
txnDate: '24-01-2026',
txnTime: '11:52:14',
txnAmount: 2300000,
instrumentType: 'Cash',
instrumentId: 'CASH',
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: 'SUNITA CHAUDHARY',
detectedOn: '25-01-2026',
flaggedBy: 'AML Engine — Rule R-018',
riskCategory: 'structuring',
narrative:
'Six cash deposits of ₹3.8L each across four branches on the same day, aggregating ₹23L — deliberate splitting to stay under the ₹10L single-transaction CTR limit.',
status: 'po-review' status: 'po-review'
} }
]; ];
/** ── KC1 sample individual customers ── */ /** ── KC1 individual customer cards (one per CTR report reference) ── */
export const KC1_INDIVIDUALS: KycIndividual[] = [ export const KC1_INDIVIDUALS: KycIndividual[] = [
{ {
reportRefNo: 1, reportRefNo: 1,
ucic: '10078', ucic: '30115',
pan: 'AAQPS8560J', pan: 'AFTPK4471B',
ckycNumber: '60011225033445', ckycNumber: '60031115447722',
firstName: 'RAVI', firstName: 'ANAND',
middleName: 'KUMAR', middleName: '',
lastName: 'SHARMA', lastName: 'KRISHNAN',
fatherName: 'RAJESH KUMAR SHARMA', fatherName: 'SUBRAMANIAN KRISHNAN',
motherName: 'SUNITA DEVI', motherName: 'VASANTHA KRISHNAN',
gender: 'Male', gender: 'Male',
dob: '10-08-1985', dob: '14-06-1979',
mobileNumber: '91-9892011001', mobileNumber: '91-9840133015',
email: 'ravi.sharma@example.com', email: 'anand.krishnan@example.com',
primaryAddress: 'FLAT 402 SUNRISE TOWERS MAROL ANDHERI EAST', primaryAddress: 'NO 18 SECOND STREET ADYAR',
state: 'MH', state: 'TN',
city: 'MUMBAI', city: 'CHENNAI',
pincode: '400069', pincode: '600020',
customerType: 'Individual', customerType: 'Individual',
annualIncome: 750000, annualIncome: 2200000,
occupation: 'Service', occupation: 'Business',
onboardingDate: '15-03-2020', onboardingDate: '22-07-2016',
lastKycDate: '12-04-2025', lastKycDate: '18-06-2025',
riskLevel: 'Medium', riskLevel: 'Medium',
reportType: 'CTR', reportType: 'CTR',
otherIdentifierName: 'AADHAAR', otherIdentifierName: 'AADHAAR',
otherIdentifierNumber: 'XXXXXXXX1234' otherIdentifierNumber: 'XXXXXXXX4471'
}, },
{ {
reportRefNo: 2, reportRefNo: 2,
ucic: '10079', ucic: '30228',
pan: 'AKLPS9933P', pan: 'AJSPS6612D',
ckycNumber: '60022144558899', ckycNumber: '60032228661133',
firstName: 'PRIYA', firstName: 'MEERA',
middleName: '', middleName: '',
lastName: 'PATEL', lastName: 'SUNDARAM',
fatherName: 'HARISH PATEL', fatherName: 'RAGHAVAN SUNDARAM',
motherName: 'MEENA PATEL', motherName: 'KALYANI SUNDARAM',
gender: 'Female', gender: 'Female',
dob: '22-03-1990', dob: '29-11-1986',
mobileNumber: '91-9822055002', mobileNumber: '91-9886044028',
email: 'priya.patel@example.com', email: 'meera.sundaram@example.com',
primaryAddress: 'B-12 SHIVAJI NAGAR FC ROAD', primaryAddress: 'FLAT 9C BRIGADE HERITAGE WHITEFIELD',
state: 'MH', state: 'KA',
city: 'PUNE', city: 'BENGALURU',
pincode: '411005', pincode: '560066',
customerType: 'Individual', customerType: 'Individual',
annualIncome: 480000, annualIncome: 3600000,
occupation: 'Self-Employed', occupation: 'Business',
onboardingDate: '10-09-2023', onboardingDate: '11-01-2019',
lastKycDate: '15-03-2025', lastKycDate: '05-05-2025',
riskLevel: 'Low', riskLevel: 'High',
reportType: 'CTR', reportType: 'CTR',
otherIdentifierName: 'AADHAAR', otherIdentifierName: 'AADHAAR',
otherIdentifierNumber: 'XXXXXXXX2210' otherIdentifierNumber: 'XXXXXXXX6612'
}, },
{ {
reportRefNo: 3, reportRefNo: 3,
ucic: '20101', ucic: '30334',
pan: 'AAEPR9352L', pan: 'AMBPB9027F',
ckycNumber: '', ckycNumber: '',
firstName: 'VIKRAM', firstName: 'HARPREET',
middleName: '', middleName: 'SINGH',
lastName: 'REDDY', lastName: 'BEDI',
fatherName: 'SURESH REDDY', fatherName: 'GURMEET SINGH BEDI',
motherName: 'LAKSHMI REDDY', motherName: 'JASWINDER KAUR',
gender: 'Male', gender: 'Male',
dob: '07-07-1982', dob: '03-02-1974',
mobileNumber: '91-9845022004', mobileNumber: '91-9815066034',
email: '', email: '',
primaryAddress: 'NO 23 4TH CROSS JAYANAGAR 5TH BLOCK', primaryAddress: 'HOUSE 221 SECTOR 34A CHANDIGARH ROAD',
state: 'KA', state: 'PB',
city: 'BENGALURU', city: 'LUDHIANA',
pincode: '560041', pincode: '141001',
customerType: 'Individual', customerType: 'Individual',
annualIncome: 950000, annualIncome: 1450000,
occupation: 'Profession', occupation: 'Business',
onboardingDate: '12-11-2021', onboardingDate: '30-08-2012',
lastKycDate: '20-02-2025', lastKycDate: '14-03-2025',
riskLevel: 'Medium', riskLevel: 'Medium',
reportType: 'CTR', reportType: 'CTR',
otherIdentifierName: 'AADHAAR', otherIdentifierName: 'AADHAAR',
otherIdentifierNumber: 'XXXXXXXX6622' otherIdentifierNumber: 'XXXXXXXX9027'
}, },
{ {
reportRefNo: 4, reportRefNo: 4,
ucic: '40301', ucic: '30447',
pan: 'AHKPS5544Z', pan: 'ANQPK3358H',
ckycNumber: '60040301229988', ckycNumber: '60034447335844',
firstName: 'NIRAV', firstName: 'FAROOQ',
middleName: '', middleName: 'ABDULLAH',
lastName: 'SHAH', lastName: 'KHAN',
fatherName: 'JAYESH SHAH', fatherName: 'ISHTIAQ AHMED KHAN',
motherName: 'RITA SHAH', motherName: 'NASREEN BEGUM',
gender: 'Male', gender: 'Male',
dob: '18-05-1992', dob: '17-09-1981',
mobileNumber: '91-9824012010', mobileNumber: '91-9701077047',
email: 'nirav.shah@example.com', email: 'farooq.khan@example.com',
primaryAddress: 'A-401 ATLANTIS APARTMENTS SATELLITE ROAD', primaryAddress: '12-2-417 MEHDIPATNAM MAIN ROAD',
state: 'GJ', state: 'TG',
city: 'AHMEDABAD', city: 'HYDERABAD',
pincode: '380015', pincode: '500028',
customerType: 'Individual', customerType: 'Individual',
annualIncome: 1800000, annualIncome: 890000,
occupation: 'Service', occupation: 'Business',
onboardingDate: '14-02-2018', onboardingDate: '05-04-2020',
lastKycDate: '20-12-2024', lastKycDate: '09-01-2025',
riskLevel: 'High', riskLevel: 'High',
reportType: 'CTR', reportType: 'CTR',
otherIdentifierName: 'PASSPORT', otherIdentifierName: 'PASSPORT',
otherIdentifierNumber: 'M5544321' otherIdentifierNumber: 'P3358214'
}, },
{ {
reportRefNo: 5, reportRefNo: 5,
ucic: '50402', ucic: '30552',
pan: 'AKHPV2244R', pan: 'APLPR7783C',
ckycNumber: '', ckycNumber: '60035552778355',
firstName: 'KAVITA', firstName: 'DEEPIKA',
middleName: '', middleName: '',
lastName: 'VERMA', lastName: 'RANGANATHAN',
fatherName: 'DEEPAK VERMA', fatherName: 'MOHAN RANGANATHAN',
motherName: 'USHA VERMA', motherName: 'SHOBHA RANGANATHAN',
gender: 'Female', gender: 'Female',
dob: '01-04-1988', dob: '08-12-1991',
mobileNumber: '91-9415055014', mobileNumber: '91-9739088052',
email: '', email: 'deepika.r@example.com',
primaryAddress: '45 CIVIL LINES MALL ROAD', primaryAddress: 'VILLA 7 PALM MEADOWS SARJAPUR ROAD',
state: 'UP', state: 'KA',
city: 'KANPUR', city: 'BENGALURU',
pincode: '208001', pincode: '560035',
customerType: 'Individual', customerType: 'Individual',
annualIncome: 540000, annualIncome: 2750000,
occupation: 'Service', occupation: 'Profession',
onboardingDate: '08-09-2022', onboardingDate: '19-06-2021',
lastKycDate: '15-09-2024', lastKycDate: '27-07-2025',
riskLevel: 'Low', riskLevel: 'Low',
reportType: 'CTR', reportType: 'CTR',
otherIdentifierName: 'AADHAAR', otherIdentifierName: 'AADHAAR',
otherIdentifierNumber: 'XXXXXXXX2244' otherIdentifierNumber: 'XXXXXXXX7783'
},
{
reportRefNo: 6,
ucic: '30669',
pan: 'AQWPY2194K',
ckycNumber: '',
firstName: 'RAJENDRA',
middleName: 'PRASAD',
lastName: 'YADAV',
fatherName: 'BHOLA NATH YADAV',
motherName: 'KAMLA DEVI',
gender: 'Male',
dob: '25-05-1968',
mobileNumber: '91-9451099069',
email: '',
primaryAddress: '78 GOMTI NAGAR VIBHUTI KHAND',
state: 'UP',
city: 'LUCKNOW',
pincode: '226010',
customerType: 'Individual',
annualIncome: 640000,
occupation: 'Agriculture',
onboardingDate: '17-10-2014',
lastKycDate: '22-11-2024',
riskLevel: 'Low',
reportType: 'CTR',
otherIdentifierName: 'AADHAAR',
otherIdentifierNumber: 'XXXXXXXX2194'
},
{
reportRefNo: 7,
ucic: '30774',
pan: 'BXKPT7788M',
ckycNumber: '',
firstName: 'SHABANA',
middleName: 'TARIQ',
lastName: 'MIRZA',
fatherName: 'TARIQ HUSSAIN MIRZA',
motherName: 'RUKHSANA MIRZA',
gender: 'Female',
dob: '11-03-1989',
mobileNumber: '91-9958100074',
email: '',
primaryAddress: '54 ZAKIR NAGAR OKHLA',
state: 'DL',
city: 'NEW DELHI',
pincode: '110025',
customerType: 'Individual',
annualIncome: 720000,
occupation: 'Self-Employed',
onboardingDate: '02-02-2023',
lastKycDate: '16-08-2025',
riskLevel: 'High',
reportType: 'CTR',
otherIdentifierName: 'AADHAAR',
otherIdentifierNumber: 'XXXXXXXX7788'
} }
]; ];
/** ── KC2 sample entity customers ── */ /** ── KC2 entity customer cards ── */
export const KC2_ENTITIES: KycEntity[] = [ export const KC2_ENTITIES: KycEntity[] = [
{ {
reportRefNo: 11, reportRefNo: 2,
ucic: 'E10101', ucic: 'E30228',
entityName: 'BHARAT TRADING COMPANY', entityName: 'SUNDARAM BULLION TRADERS',
panNumber: 'ABCFB1234N', panNumber: 'AAGFS3391Q',
gstin: '27ABCFB1234N1ZT', gstin: '29AAGFS3391Q1ZP',
mobileNumber: '91-9892011010', mobileNumber: '91-9886044028',
address: 'GALA 22 LOWER PAREL', address: 'SHOP 14 COMMERCIAL STREET SHIVAJI NAGAR',
state: 'MH', state: 'KA',
city: 'MUMBAI', city: 'BENGALURU',
pincode: '400013', pincode: '560001',
dateOfIncorporation: '24-03-2008', dateOfIncorporation: '11-01-2019',
customerType: 'Sole Proprietor', customerType: 'Sole Proprietor',
riskLevel: 'High', riskLevel: 'High',
onboardingDate: '24-03-2008', onboardingDate: '11-01-2019',
lastKycDate: '03-09-2025', lastKycDate: '05-05-2025',
isSoleProprietor: true, isSoleProprietor: true,
ownerUcic: '10078', ownerUcic: '30228',
ownerName: 'RAVI KUMAR SHARMA', ownerName: 'MEERA SUNDARAM',
reportType: 'CTR' reportType: 'CTR'
}, },
{ {
reportRefNo: 12, reportRefNo: 4,
ucic: 'E10102', ucic: 'E30447',
entityName: 'NEXTGEN TECH PVT LTD', entityName: 'DECCAN SCRAP & METALS PVT LTD',
panNumber: 'AABCN5566K', panNumber: 'AABCD8842R',
gstin: '27AABCN5566K1Z2', gstin: '36AABCD8842R1ZN',
mobileNumber: '91-9824012011', mobileNumber: '91-9701077047',
address: 'TOWER B SUNRISE BUSINESS PARK ANDHERI EAST', address: 'PLOT 61 IDA JEEDIMETLA PHASE II',
state: 'MH', state: 'TG',
city: 'MUMBAI', city: 'HYDERABAD',
pincode: '400093', pincode: '500055',
dateOfIncorporation: '18-09-2015', dateOfIncorporation: '05-04-2020',
customerType: 'Private Limited', customerType: 'Private Limited',
riskLevel: 'Medium', riskLevel: 'High',
onboardingDate: '18-09-2015', onboardingDate: '05-04-2020',
lastKycDate: '12-08-2025', lastKycDate: '09-01-2025',
isSoleProprietor: false, isSoleProprietor: false,
ownerUcic: '', ownerUcic: '',
ownerName: '', ownerName: '',
+273 -26
View File
@@ -71,17 +71,19 @@
<div class="fiu-kpi fiu-kpi--amber"> <div class="fiu-kpi fiu-kpi--amber">
<div class="fiu-kpi-ic"><fa-icon icon="exclamation-triangle"></fa-icon></div> <div class="fiu-kpi-ic"><fa-icon icon="exclamation-triangle"></fa-icon></div>
<div> <div>
<div class="fiu-kpi-n">{{ pendingStr | number }}</div> <div class="fiu-kpi-n">{{ liveStrPending | number }}</div>
<div class="fiu-kpi-l">STR Pending</div> <div class="fiu-kpi-l">STR Pending</div>
<div class="fiu-kpi-sub">draft / PO review</div> <div class="fiu-kpi-sub">
{{ liveStrOpen }} to investigate &middot; {{ liveStrAwaitingFiling }} awaiting filing
</div>
</div> </div>
</div> </div>
<div class="fiu-kpi fiu-kpi--green"> <div class="fiu-kpi fiu-kpi--green">
<div class="fiu-kpi-ic"><fa-icon icon="check-circle"></fa-icon></div> <div class="fiu-kpi-ic"><fa-icon icon="check-circle"></fa-icon></div>
<div> <div>
<div class="fiu-kpi-n">{{ filedStr | number }}</div> <div class="fiu-kpi-n">{{ liveStrFiled | number }}</div>
<div class="fiu-kpi-l">STR Filed</div> <div class="fiu-kpi-l">STR Filed</div>
<div class="fiu-kpi-sub">acknowledged by FIU</div> <div class="fiu-kpi-sub">{{ liveStrCleared }} concluded clear</div>
</div> </div>
</div> </div>
<div class="fiu-kpi fiu-kpi--purple"> <div class="fiu-kpi fiu-kpi--purple">
@@ -112,7 +114,12 @@
</div> </div>
<div class="dc-meta">Filing window: <strong>within 7 days of detection</strong></div> <div class="dc-meta">Filing window: <strong>within 7 days of detection</strong></div>
<div class="dc-meta"> <div class="dc-meta">
Pending: <strong>{{ pendingStr }}</strong> &middot; Filed: <strong>{{ filedStr }}</strong> To investigate: <strong>{{ liveStrOpen }}</strong> &middot; Awaiting filing:
<strong>{{ liveStrAwaitingFiling }}</strong>
</div>
<div class="dc-meta">
Filed: <strong>{{ liveStrFiled }}</strong> &middot; Concluded clear:
<strong>{{ liveStrCleared }}</strong>
</div> </div>
<button class="dc-btn" (click)="showTab('str')"><fa-icon icon="external-link-alt"></fa-icon> Open</button> <button class="dc-btn" (click)="showTab('str')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div> </div>
@@ -129,12 +136,12 @@
<!-- CTR --> <!-- CTR -->
@if (activeTab === 'ctr') { @if (activeTab === 'ctr') {
<div class="fiu-section"> <div class="fiu-section">
<!-- LIVE: cash-related AML alerts awaiting AML Officer review --> <!-- LIVE: cash-related CTR threshold alerts -->
<div class="fiu-form-meta"> <div class="fiu-form-meta">
<div class="fiu-form-title"> <div class="fiu-form-title">
<h3>Live Cash Alerts</h3> <h3>Live CTR Threshold Alerts</h3>
<p> <p>
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer Mechanical cash transaction threshold breaches — review and whitelist or escalate to Principal Officer
</p> </p>
</div> </div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()"> <select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
@@ -156,12 +163,13 @@
<th class="ta-r">Amount</th> <th class="ta-r">Amount</th>
<th>Raised On</th> <th>Raised On</th>
<th>Status</th> <th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th> <th>Notes</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (alert of amlAlerts; track alert.id) { @for (alert of ctrAlerts; track alert.id) {
<tr> <tr>
<td>{{ alert.id }}</td> <td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td> <td>{{ alert.clientDisplayName }}</td>
@@ -172,6 +180,9 @@
<td> <td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span> <span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td> </td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status)">{{ alertRouteLabel(alert.status) }}</span>
</td>
<td> <td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') { @if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input <input
@@ -195,12 +206,17 @@
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="9" class="fiu-empty">No live alerts.</td> <td colspan="10" class="fiu-empty">No live alerts.</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </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
</p>
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) --> <!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
<div class="fiu-form-meta"> <div class="fiu-form-meta">
@@ -295,6 +311,8 @@
<th>Instrument</th> <th>Instrument</th>
<th>Branch</th> <th>Branch</th>
<th class="ta-c">Relationship</th> <th class="ta-c">Relationship</th>
<th class="ta-c">Filed Under</th>
<th>Compliance Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -327,21 +345,131 @@
t.relationshipFlag t.relationshipFlag
}}</span> }}</span>
</td> </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>
}
</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>
</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>
}
}
</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="9" class="fiu-empty">No transactions match the filters.</td> <td colspan="11" class="fiu-empty">No transactions match the filters.</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </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)
</p>
</div> </div>
} }
<!-- STR --> <!-- STR -->
@if (activeTab === 'str') { @if (activeTab === 'str') {
<div class="fiu-section"> <div class="fiu-section">
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<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>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlSuspiciousAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<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>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts.</td>
</tr>
}
</tbody>
</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-cleared">Cleared</span> whitelisted — no report filed &middot;
<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;" />
<!-- LIVE: STR cases with full Principal Officer actions --> <!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta"> <div class="fiu-form-meta">
<div class="fiu-form-title"> <div class="fiu-form-title">
@@ -407,6 +535,17 @@
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button> <button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
</div> </div>
<div
class="case-outcome"
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
[class.case-outcome--suspicious]="
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
"
>
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
</div>
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'"> <div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="form-row"> <div class="form-row">
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label> <label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
@@ -416,17 +555,96 @@
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label> <label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea> <textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
</div> </div>
<div class="form-row conclusion-row"> <!-- Verification checklist — the verdict is derived, not chosen. -->
<label> <div class="checklist">
<input type="radio" name="fiuConclusion" value="NOT_SUSPICIOUS" [(ngModel)]="concludeConclusion" /> <div class="checklist-head">
Not suspicious (whitelist) <span>Principal Officer verification — mark each point as found</span>
</label> <span class="checklist-progress" [class.done]="allAnswered">
<label> {{ answeredCount }} / {{ strChecklist.length }} answered
<input type="radio" name="fiuConclusion" value="SUSPICIOUS" [(ngModel)]="concludeConclusion" /> </span>
Suspicious (file STR) </div>
</label> <table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-pressed]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-pressed]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div> </div>
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
<!-- Derived verdict -->
<div
class="verdict"
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
>
@if (!allAnswered) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Answer every verification point to determine the outcome —
{{ strChecklist.length - answeredCount }} still pending.
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will
be closed and nothing filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Suspicious — STR must be filed.</strong>
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
{{ failedFindings[0].label }}@if (failedFindings.length > 1) {
<span> and {{ failedFindings.length - 1 }} more</span>
}.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
Conclude as {{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
</button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') { @if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
<div class="file-str-row"> <div class="file-str-row">
@@ -478,7 +696,12 @@
<div class="fiu-str-list"> <div class="fiu-str-list">
@for (s of filteredStr; track s.reportRefNo) { @for (s of filteredStr; track s.reportRefNo) {
<div class="str-card" [class.str-card--filed]="s.status === 'filed'"> <div
class="str-card"
[id]="'str-card-' + s.reportRefNo"
[class.str-card--filed]="s.status === 'filed'"
[class.str-card--highlight]="highlightedStrRef === s.reportRefNo"
>
<div class="str-card-h"> <div class="str-card-h">
<div class="str-card-id"> <div class="str-card-id">
<span class="str-ref">STR-{{ s.reportRefNo }}</span> <span class="str-ref">STR-{{ s.reportRefNo }}</span>
@@ -521,6 +744,27 @@
<p>{{ s.poRemarks }}</p> <p>{{ s.poRemarks }}</p>
</div> </div>
} }
@if (s.poFinding) {
<div class="str-finding" [class.str-finding--suspicious]="s.poFinding === 'SUSPICIOUS'">
<label>
<fa-icon [icon]="s.poFinding === 'CLEAN' ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
Principal Officer Finding
</label>
<p>{{ poFindingLabel(s.poFinding) }}</p>
</div>
} @else {
<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>
</div>
</div>
}
</div> </div>
</div> </div>
} @empty { } @empty {
@@ -626,10 +870,13 @@
<div class="fiu-form-meta"> <div class="fiu-form-meta">
<div class="fiu-form-title"> <div class="fiu-form-title">
<h3>FINnet 2.0 Submission Files</h3> <h3>FINnet 2.0 Submission Files</h3>
<p>6 CSV files in FIU-IND prescribed schema, plus STR XML</p> <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.
</p>
</div> </div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()"> <button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download All <fa-icon icon="file-csv"></fa-icon> Download All 7
</button> </button>
</div> </div>
@@ -641,7 +888,7 @@
</div> </div>
<div class="file-card"> <div class="file-card">
<div class="file-card-h"><fa-icon icon="file-csv"></fa-icon>TC2.csv</div> <div class="file-card-h"><fa-icon icon="file-csv"></fa-icon>TC2.csv</div>
<p>Branch / cash transactions ({{ ctrTxns.length }} rows)</p> <p>Branch / cash transactions ({{ filteredCtr.length }} rows)</p>
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()"> <button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
<fa-icon icon="file-csv"></fa-icon> Download <fa-icon icon="file-csv"></fa-icon> Download
</button> </button>
@@ -676,7 +923,7 @@
</div> </div>
<div class="file-card file-card--xml"> <div class="file-card file-card--xml">
<div class="file-card-h"><fa-icon icon="file-alt"></fa-icon>STR.xml</div> <div class="file-card-h"><fa-icon icon="file-alt"></fa-icon>STR.xml</div>
<p>Suspicious Transaction Report ({{ filedStr + pendingStr }} suspects)</p> <p>Suspicious Transaction Report ({{ strSuspects.length }} suspects — cases concluded clean are excluded)</p>
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()"> <button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
<fa-icon icon="file-alt"></fa-icon> Download XML <fa-icon icon="file-alt"></fa-icon> Download XML
</button> </button>
+341
View File
@@ -386,6 +386,100 @@
align-self: end; align-self: end;
} }
.fiu-link-btn {
display: inline-block;
margin-top: 3px;
padding: 0;
border: none;
background: none;
font-family: inherit;
font-size: 11px;
font-weight: 600;
color: var(--f-red);
cursor: pointer;
text-decoration: underline;
&:hover {
color: #9a2f22;
}
&--muted {
color: var(--f-g500);
&:hover {
color: var(--f-red);
}
}
}
/* PRINCIPAL OFFICER INVESTIGATION */
.str-po-action {
background: var(--f-amber-l);
border: 1px dashed #fcd34d;
border-radius: 8px;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
> label {
font-size: 10px;
font-weight: 700;
color: #92400e;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.fiu-btn-clean {
background: var(--f-green);
border-color: var(--f-green);
color: #fff;
&:hover {
background: #166534;
border-color: #166534;
color: #fff;
}
}
.str-finding {
background: var(--f-green-l);
border-radius: 8px;
padding: 10px 14px;
label {
font-size: 10px;
font-weight: 700;
color: var(--f-green);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 4px;
}
p {
margin: 0;
font-size: 13px;
color: #14532d;
line-height: 1.5;
}
&--suspicious {
background: var(--f-red-l);
label {
color: var(--f-red);
}
p {
color: #7f1d1d;
}
}
}
/* INPUTS */ /* INPUTS */
.fiu-inp { .fiu-inp {
width: 100%; width: 100%;
@@ -538,6 +632,50 @@
} }
} }
.report-pill {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
&.report-ctr {
background: var(--f-blue-l);
color: var(--f-blue);
}
&.report-str {
background: var(--f-red-l);
color: var(--f-red);
margin-left: 4px;
}
&.report-pending {
background: var(--f-amber-l);
color: var(--f-amber);
}
&.report-cleared {
background: var(--f-green-l);
color: var(--f-green);
}
}
.fiu-tbl-legend {
margin: -6px 2px 0;
font-size: 11px;
color: var(--f-g500);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
.report-pill {
margin-left: 0;
}
}
.rel-pill { .rel-pill {
display: inline-block; display: inline-block;
padding: 2px 9px; padding: 2px 9px;
@@ -697,6 +835,11 @@
&--filed { &--filed {
border-color: #86efac; border-color: #86efac;
} }
&--highlight {
border-color: var(--f-red);
box-shadow: 0 0 0 3px rgb(192 57 43 / 20%);
}
} }
.str-card-h { .str-card-h {
@@ -956,6 +1099,29 @@
margin-bottom: 16px; margin-bottom: 16px;
} }
.case-outcome {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: 12px 0;
padding: 10px 14px;
border-radius: 8px;
background: var(--f-g100);
font-size: 13px;
color: var(--f-g700);
&--clear {
background: var(--f-green-l);
color: #14532d;
}
&--suspicious {
background: var(--f-red-l);
color: #7f1d1d;
}
}
.case-form { .case-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -995,6 +1161,181 @@
} }
} }
/* ── Principal Officer verification checklist ───────────────────────────── */
.checklist {
border: 1px solid var(--f-g200);
border-radius: 10px;
overflow: hidden;
background: #fff;
}
.checklist-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--f-g50);
border-bottom: 1px solid var(--f-g200);
font-size: 12px;
font-weight: 700;
color: var(--f-g700);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.checklist-progress {
font-size: 11px;
font-weight: 700;
padding: 2px 10px;
border-radius: 999px;
background: var(--f-amber-l);
color: var(--f-amber);
text-transform: none;
letter-spacing: 0;
white-space: nowrap;
&.done {
background: var(--f-green-l);
color: var(--f-green);
}
}
.checklist-tbl {
width: 100%;
border-collapse: collapse;
th {
padding: 8px 12px;
font-size: 10px;
font-weight: 700;
color: var(--f-g500);
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--f-g50);
border-bottom: 1px solid var(--f-g200);
text-align: left;
white-space: nowrap;
}
th.col-yes {
color: var(--f-green);
width: 96px;
}
th.col-no {
color: var(--f-red);
width: 112px;
}
td {
padding: 9px 12px;
border-bottom: 1px solid var(--f-g100);
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
}
/* Answered rows tint so an unresolved finding is obvious at a glance. */
.row-yes td {
background: rgb(220 252 231 / 45%);
}
.row-no td {
background: rgb(253 236 234 / 60%);
}
.row-pending td {
background: #fff;
}
.chk-label {
font-size: 13px;
font-weight: 600;
color: var(--f-g900);
}
.chk-hint {
font-size: 11px;
color: var(--f-g500);
margin-top: 1px;
}
/* Two-sided toggle: one box on the genuine side, one on the not-satisfied side. */
.chk-box {
width: 30px;
height: 30px;
border-radius: 7px;
border: 2px solid var(--f-g300);
background: #fff;
color: var(--f-g300);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.15s;
&:hover {
transform: scale(1.08);
}
&--yes:hover {
border-color: var(--f-green);
color: var(--f-green);
}
&--no:hover {
border-color: var(--f-red);
color: var(--f-red);
}
&--yes.on {
border-color: var(--f-green);
background: var(--f-green);
color: #fff;
}
&--no.on {
border-color: var(--f-red);
background: var(--f-red);
color: #fff;
}
}
/* Derived verdict banner */
.verdict {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 15px;
border-radius: 9px;
font-size: 13px;
background: var(--f-g100);
border: 1px solid var(--f-g200);
color: var(--f-g700);
&--clear {
background: var(--f-green-l);
border-color: #86efac;
color: #14532d;
}
&--suspicious {
background: var(--f-red-l);
border-color: #fca5a5;
color: #7f1d1d;
}
}
.case-form .fiu-btn-xml:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-str-row { .file-str-row {
display: flex; display: flex;
align-items: center; align-items: center;
+344 -30
View File
@@ -20,6 +20,7 @@ import {
KC2_ENTITIES, KC2_ENTITIES,
KycEntity, KycEntity,
KycIndividual, KycIndividual,
PoFinding,
StrStatus, StrStatus,
StrTransaction, StrTransaction,
STR_TXNS STR_TXNS
@@ -36,6 +37,9 @@ import {
] ]
}) })
export class FiuComponent implements OnInit { export class FiuComponent implements OnInit {
/** Bump when CTR_TXNS / STR_TXNS change so cached rows are re-seeded. */
private static readonly SEED_VERSION = '2026-01-cash-threshold';
private amlService = inject(AmlService); private amlService = inject(AmlService);
private authenticationService = inject(AuthenticationService); private authenticationService = inject(AuthenticationService);
/** Active tab */ /** Active tab */
@@ -56,6 +60,9 @@ export class FiuComponent implements OnInit {
strFilterStatus: '' | StrStatus = ''; strFilterStatus: '' | StrStatus = '';
strFilterRisk: string = ''; strFilterRisk: string = '';
/** Ref of the STR card to highlight after jumping across from the CTR tab */
highlightedStrRef: number | null = null;
/** Toast */ /** Toast */
toastMsg = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
@@ -76,6 +83,89 @@ export class FiuComponent implements OnInit {
concludeSourceOfFundsNotes = ''; concludeSourceOfFundsNotes = '';
fileReferenceNo = ''; fileReferenceNo = '';
/**
* The Principal Officer's verification checklist. Each point is answered
* "Genuine" or "Not satisfied"; the case conclusion is derived from the answers
* rather than picked by hand, so a case with an unresolved finding cannot be
* whitelisted by accident.
*/
readonly strChecklist: { key: string; label: string; hint: string }[] = [
{
key: 'sourceOfFunds',
label: 'Source of funds established and documented',
hint: 'Salary slips, sale deed, business receipts or other credible evidence on file'
},
{
key: 'profileConsistent',
label: 'Transaction consistent with the customer’s declared profile',
hint: 'Value and frequency in line with declared income, occupation and turnover'
},
{
key: 'kycCurrent',
label: 'KYC records complete and within re-KYC validity',
hint: 'Identity, address and beneficial-ownership records verified and current'
},
{
key: 'screeningClear',
label: 'Sanctions, PEP and adverse-media screening clear',
hint: 'No UNSCC / MHA listing, no PEP linkage, no negative news on the customer'
},
{
key: 'patternConsistent',
label: 'Pattern consistent with the account’s own history',
hint: 'No structuring, sudden dormancy revival or rapid pass-through observed'
},
{
key: 'counterpartyKnown',
label: 'Counterparty identifiable and legitimate',
hint: 'Remitter / beneficiary traceable and holding a plausible commercial relationship'
},
{
key: 'documentsGenuine',
label: 'Supporting documents verified as genuine',
hint: 'Invoices, contracts and bank statements independently corroborated'
},
{
key: 'noLawEnforcement',
label: 'No law-enforcement or regulatory reference pending',
hint: 'No summons, freeze order or LEA request outstanding against the account'
}
];
/** null = not yet answered, true = genuine, false = not satisfied. */
strFindings: Record<string, boolean | null> = {};
private resetChecklist(): void {
this.strFindings = {};
for (const item of this.strChecklist) {
this.strFindings[item.key] = null;
}
}
setFinding(key: string, genuine: boolean): void {
this.strFindings[key] = genuine;
this.concludeConclusion = this.derivedConclusion;
}
get answeredCount(): number {
return this.strChecklist.filter((i) => this.strFindings[i.key] !== null).length;
}
get allAnswered(): boolean {
return this.answeredCount === this.strChecklist.length;
}
/** Points the officer marked as not satisfied — these are what force an STR. */
get failedFindings(): { key: string; label: string; hint: string }[] {
return this.strChecklist.filter((i) => this.strFindings[i.key] === false);
}
/**
* Everything genuine means the case is cleared; a single unsatisfied point
* means it must be filed as suspicious.
*/
get derivedConclusion(): 'NOT_SUSPICIOUS' | 'SUSPICIOUS' {
return this.failedFindings.length > 0 ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
}
get isPrincipalOfficer(): boolean { get isPrincipalOfficer(): boolean {
return this.authenticationService.isPrincipalOfficerUser(); return this.authenticationService.isPrincipalOfficerUser();
} }
@@ -88,6 +178,84 @@ export class FiuComponent implements OnInit {
this.loadAmlAlerts(); this.loadAmlAlerts();
this.loadAmlCtrReports(); this.loadAmlCtrReports();
this.loadAmlStrCases(); this.loadAmlStrCases();
// Bumping SEED_VERSION discards previously cached rows so a new data set takes effect.
if (localStorage.getItem('fiu_seed_version') !== FiuComponent.SEED_VERSION) {
localStorage.setItem('fiu_seed_version', FiuComponent.SEED_VERSION);
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
return;
}
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
this.ctrTxns = JSON.parse(storedCtr);
} else {
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
}
const storedStr = localStorage.getItem('fiu_str_cases');
if (storedStr) {
this.strTxns = JSON.parse(storedStr);
} else {
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
}
}
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`);
}
flagCtrTxnSuspicious(txn: CtrTransaction): void {
txn.complianceStatus = 'SUSPICIOUS';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
const newStrCase: StrTransaction = {
...txn,
reportType: 'STR',
reportRefNo: newRef,
detectedOn: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
flaggedBy: 'AML Officer',
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.`,
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`);
}
/** Principal Officer closes an STR case as legitimate, or confirms it and files an STR. */
concludeLocalStr(rec: StrTransaction, finding: PoFinding): void {
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`);
} else {
rec.status = 'filed';
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`);
}
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
}
poFindingLabel(finding: PoFinding): string {
return finding === 'CLEAN'
? 'Clean — source of funds verified as legitimate. No STR filed.'
: 'Suspicious — STR filed with FIU-IND and account escalated.';
} }
loadAmlAlerts(): void { loadAmlAlerts(): void {
@@ -104,23 +272,35 @@ export class FiuComponent implements OnInit {
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || [])); this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
} }
/** Surfaces a failed backend call — without this a rejected request looks like a dead button. */
private apiError(action: string) {
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
}
whitelistAlert(alert: any): void { whitelistAlert(alert: any): void {
this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe(() => { this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe({
this.toast(`Alert #${alert.id} whitelisted`); next: () => {
this.loadAmlAlerts(); this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
this.loadAmlAlerts();
},
error: this.apiError(`Whitelisting alert #${alert.id}`)
}); });
} }
escalateAlert(alert: any): void { escalateAlert(alert: any): void {
this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe(() => { this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe({
this.toast(`Alert #${alert.id} escalated to Principal Officer`); next: () => {
this.loadAmlAlerts(); this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
this.loadAmlStrCases(); this.loadAmlAlerts();
this.loadAmlStrCases();
},
error: this.apiError(`Escalating alert #${alert.id}`)
}); });
} }
generateCtrReport(): void { generateCtrReport(): void {
if (!this.ctrGenerateMonth) { if (!this.ctrGenerateMonth) {
this.toast('Pick a reporting month first');
return; return;
} }
this.amlService.generateCtrReport(this.ctrGenerateMonth).subscribe({ this.amlService.generateCtrReport(this.ctrGenerateMonth).subscribe({
@@ -128,16 +308,32 @@ export class FiuComponent implements OnInit {
this.toast(`CTR report generated for ${this.ctrGenerateMonth}`); this.toast(`CTR report generated for ${this.ctrGenerateMonth}`);
this.loadAmlCtrReports(); this.loadAmlCtrReports();
}, },
error: (err) => this.toast(err?.error?.defaultUserMessage || 'CTR report generation failed') error: this.apiError('CTR report generation')
}); });
} }
selectStrCase(strCase: any): void { selectStrCase(strCase: any): void {
this.selectedStrCase = strCase; this.selectedStrCase = strCase;
this.concludeConclusion = 'NOT_SUSPICIOUS'; // Preserve an existing verdict instead of silently resetting it to NOT_SUSPICIOUS.
this.concludeConclusion = strCase.conclusion === 'SUSPICIOUS' ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
this.concludeInvestigationNotes = strCase.investigationNotes || ''; this.concludeInvestigationNotes = strCase.investigationNotes || '';
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || ''; this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
this.fileReferenceNo = ''; this.fileReferenceNo = strCase.strReferenceNo || '';
this.resetChecklist();
}
/** Plain-language outcome for a live STR case, shown in the investigation panel. */
strCaseOutcome(strCase: any): string {
switch (strCase?.status) {
case 'CONCLUDED_CLEAR':
return 'Concluded not suspicious — case closed, nothing filed with FIU-IND.';
case 'CONCLUDED_SUSPICIOUS':
return `Concluded suspicious — STR must be filed with FIU-IND by ${strCase.fiuDueDate || 'the due date'}.`;
case 'FILED':
return `STR filed with FIU-IND${strCase.strReferenceNo ? ` under reference ${strCase.strReferenceNo}` : ''}.`;
default:
return 'Open — awaiting the Principal Officer’s conclusion.';
}
} }
closeStrCaseDetail(): void { closeStrCaseDetail(): void {
@@ -148,32 +344,60 @@ export class FiuComponent implements OnInit {
if (!this.selectedStrCase) { if (!this.selectedStrCase) {
return; return;
} }
if (!this.allAnswered) {
const pending = this.strChecklist.length - this.answeredCount;
this.toast(`Answer all verification points first — ${pending} still pending`);
return;
}
if (!this.concludeInvestigationNotes.trim()) {
this.toast('Record your investigation notes before concluding');
return;
}
// The verdict always follows the checklist, never a stale radio selection.
this.concludeConclusion = this.derivedConclusion;
// Fold the unsatisfied points into the notes so the STR carries its own reasons.
const failed = this.failedFindings;
const notes = failed.length
? `${this.concludeInvestigationNotes.trim()}\n\nUnsatisfied verification points:\n` +
failed.map((f) => `• ${f.label}`).join('\n')
: `${this.concludeInvestigationNotes.trim()}\n\nAll ${this.strChecklist.length} verification points found genuine.`;
this.amlService this.amlService
.concludeStrCase( .concludeStrCase(
this.selectedStrCase.id, this.selectedStrCase.id,
this.concludeConclusion, this.concludeConclusion,
this.concludeInvestigationNotes, notes,
this.concludeSourceOfFundsNotes this.concludeSourceOfFundsNotes
) )
.subscribe((updated) => { .subscribe({
this.toast( next: (updated) => {
this.concludeConclusion === 'SUSPICIOUS' this.toast(
? `STR case #${updated.id} concluded suspicious — FIU due date ${updated.fiuDueDate}` this.concludeConclusion === 'SUSPICIOUS'
: `STR case #${updated.id} concluded not suspicious` ? `STR case #${updated.id} concluded suspicious — file with FIU-IND by ${updated.fiuDueDate}`
); : `STR case #${updated.id} concluded not suspicious — no filing required`
this.selectedStrCase = updated; );
this.loadAmlStrCases(); this.selectedStrCase = updated;
this.loadAmlStrCases();
},
error: this.apiError(`Concluding STR case #${this.selectedStrCase.id}`)
}); });
} }
fileStrCase(): void { fileStrCase(): void {
if (!this.selectedStrCase || !this.fileReferenceNo) { if (!this.selectedStrCase) {
return; return;
} }
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe((updated) => { if (!this.fileReferenceNo.trim()) {
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`); this.toast('Enter the FIU-IND acknowledgement number first');
this.selectedStrCase = updated; return;
this.loadAmlStrCases(); }
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe({
next: (updated) => {
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
this.selectedStrCase = updated;
this.loadAmlStrCases();
},
error: this.apiError(`Filing STR case #${this.selectedStrCase.id}`)
}); });
} }
@@ -181,10 +405,57 @@ export class FiuComponent implements OnInit {
return 'status-pill status-' + (status || '').toLowerCase().replace(/_/g, '-'); return 'status-pill status-' + (status || '').toLowerCase().replace(/_/g, '-');
} }
/**
* 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).
*/
alertRouteLabel(status: string, isCtrRule: boolean = true): string {
switch (status) {
case 'WHITELISTED':
return isCtrRule ? 'CTR only' : 'Cleared';
case 'ESCALATED':
return '→ STR';
default:
return 'Pending';
}
}
alertRouteClass(status: string, isCtrRule: boolean = true): string {
switch (status) {
case 'WHITELISTED':
return isCtrRule ? 'report-pill report-ctr' : 'report-pill report-cleared';
case 'ESCALATED':
return 'report-pill report-str';
default:
return 'report-pill report-pending';
}
}
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void { showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
this.activeTab = tab; this.activeTab = tab;
} }
/**
* Jump from a flagged CTR row to its STR card. Clears any status/risk filter that
* would hide the case, then scrolls it into view and highlights it.
*/
openStrCase(txn: CtrTransaction): void {
const match = this.strTxns.find((s) => s.txnId === txn.txnId);
if (!match) {
this.toast('No STR case found for this transaction');
return;
}
this.activeTab = 'str';
this.resetStrFilters();
this.highlightedStrRef = match.reportRefNo;
setTimeout(() => {
document
.getElementById(`str-card-${match.reportRefNo}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
// -------------------- KPIs -------------------- // -------------------- KPIs --------------------
get totalReports(): number { get totalReports(): number {
return new Set(this.ctrTxns.map((t) => t.reportRefNo)).size; return new Set(this.ctrTxns.map((t) => t.reportRefNo)).size;
@@ -201,10 +472,39 @@ export class FiuComponent implements OnInit {
get filedStr(): number { get filedStr(): number {
return this.strTxns.filter((s) => s.status === 'filed').length; return this.strTxns.filter((s) => s.status === 'filed').length;
} }
// ── Live STR case counts (backend `amlStrCases`) — these are the cases the
// Principal Officer actually works through the Investigate → Conclude → File flow.
get liveStrOpen(): number {
return this.amlStrCases.filter((c) => c.status === 'OPEN').length;
}
get liveStrAwaitingFiling(): number {
return this.amlStrCases.filter((c) => c.status === 'CONCLUDED_SUSPICIOUS').length;
}
get liveStrCleared(): number {
return this.amlStrCases.filter((c) => c.status === 'CONCLUDED_CLEAR').length;
}
get liveStrFiled(): number {
return this.amlStrCases.filter((c) => c.status === 'FILED').length;
}
/** Still needs PO action: untouched, or concluded suspicious but not yet filed with FIU-IND. */
get liveStrPending(): number {
return this.liveStrOpen + this.liveStrAwaitingFiling;
}
get totalCustomers(): number { get totalCustomers(): number {
return this.kc1.length + this.kc2.length; return this.kc1.length + this.kc2.length;
} }
get ctrAlerts(): any[] {
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'];
return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
}
// -------------------- Filtered lists -------------------- // -------------------- Filtered lists --------------------
get filteredCtr(): CtrTransaction[] { get filteredCtr(): CtrTransaction[] {
const acc = this.ctrFilterAccount.trim(); const acc = this.ctrFilterAccount.trim();
@@ -224,6 +524,14 @@ export class FiuComponent implements OnInit {
}); });
} }
/**
* Cases that actually belong in an STR filing. A case the Principal Officer
* concluded clean is not a suspect and must never reach FIU-IND.
*/
get strSuspects(): StrTransaction[] {
return this.filteredStr.filter((s) => s.poFinding !== 'CLEAN' && s.status !== 'rejected');
}
resetCtrFilters(): void { resetCtrFilters(): void {
this.ctrFilterMode = ''; this.ctrFilterMode = '';
this.ctrFilterAccount = ''; this.ctrFilterAccount = '';
@@ -523,11 +831,11 @@ export class FiuComponent implements OnInit {
const rows = [headers]; const rows = [headers];
// Build one aggregate row per (reportRefNo, accountNumber) pair // Build one aggregate row per (reportRefNo, accountNumber) pair
const seen = new Set<string>(); const seen = new Set<string>();
for (const t of this.ctrTxns) { for (const t of this.filteredCtr) {
const key = `${t.reportRefNo}|${t.accountNumber}`; const key = `${t.reportRefNo}|${t.accountNumber}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
const sameKey = this.ctrTxns.filter( const sameKey = this.filteredCtr.filter(
(x) => x.reportRefNo === t.reportRefNo && x.accountNumber === t.accountNumber (x) => x.reportRefNo === t.reportRefNo && x.accountNumber === t.accountNumber
); );
const debits = sameKey.filter((x) => x.depositOrWithdrawal === 'Withdrawal'); const debits = sameKey.filter((x) => x.depositOrWithdrawal === 'Withdrawal');
@@ -576,7 +884,7 @@ export class FiuComponent implements OnInit {
]; ];
const rows = [headers]; const rows = [headers];
const seen = new Set<string>(); const seen = new Set<string>();
for (const t of this.ctrTxns) { for (const t of this.filteredCtr) {
const key = `${t.reportRefNo}|${t.accountNumber}`; const key = `${t.reportRefNo}|${t.accountNumber}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
@@ -599,12 +907,17 @@ export class FiuComponent implements OnInit {
/** STR XML stub (FINnet 2.0 STR XML) */ /** STR XML stub (FINnet 2.0 STR XML) */
exportStrXml(): void { exportStrXml(): void {
const suspects = this.strSuspects;
if (!suspects.length) {
this.toast('No suspect cases to export — nothing matches the current filters');
return;
}
const xml: string[] = []; const xml: string[] = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>'); xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.bank.reportingMonth}">`); xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.bank.reportingMonth}">`);
xml.push(` <REBank buid="${this.bank.reBuid}" name="${this.bank.name}" />`); xml.push(` <REBank buid="${this.bank.reBuid}" name="${this.bank.name}" />`);
xml.push(` <PrincipalOfficer name="${this.bank.principalOfficer}" email="${this.bank.principalOfficerEmail}" />`); xml.push(` <PrincipalOfficer name="${this.bank.principalOfficer}" email="${this.bank.principalOfficerEmail}" />`);
for (const s of this.filteredStr) { for (const s of suspects) {
xml.push(` <Suspect ref="${s.reportRefNo}" status="${s.status}">`); xml.push(` <Suspect ref="${s.reportRefNo}" status="${s.status}">`);
xml.push(` <Account>${s.accountNumber}</Account>`); xml.push(` <Account>${s.accountNumber}</Account>`);
xml.push( xml.push(
@@ -624,10 +937,10 @@ export class FiuComponent implements OnInit {
a.download = `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`; a.download = `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
this.toast(`STR XML exported (${this.filteredStr.length} suspects)`); this.toast(`STR XML exported (${suspects.length} suspects)`);
} }
/** Bundle: all 6 CSVs zipped — emitted as separate downloads sequentially */ /** Bundle: the 6 CSVs plus the STR XML — emitted as separate downloads sequentially */
exportAll(): void { exportAll(): void {
setTimeout(() => this.exportTc1(), 0); setTimeout(() => this.exportTc1(), 0);
setTimeout(() => this.exportTc2(), 200); setTimeout(() => this.exportTc2(), 200);
@@ -635,6 +948,7 @@ export class FiuComponent implements OnInit {
setTimeout(() => this.exportKc2(), 600); setTimeout(() => this.exportKc2(), 600);
setTimeout(() => this.exportAccountDetail(), 800); setTimeout(() => this.exportAccountDetail(), 800);
setTimeout(() => this.exportAccountPersonRelation(), 1000); setTimeout(() => this.exportAccountPersonRelation(), 1000);
setTimeout(() => this.exportStrXml(), 1200);
} }
private escapeXml(s: string): string { private escapeXml(s: string): string {
+496
View File
@@ -0,0 +1,496 @@
/**
* 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/.
*/
/**
* Data layer for the General Ledger report suite.
*
* Rather than hard-coding seven finished reports, this file holds a chart of
* accounts plus a generated ledger of double-entry postings. Every report is
* then computed from those postings, which is what makes the day / month /
* branch filters genuinely recompute instead of just re-labelling a static table.
*
* The generator is deterministic — a fixed-seed LCG, never Math.random — so the
* figures are identical on every reload and across users. Amounts are in rupees.
*/
export type GlAccountType = 'ASSET' | 'LIABILITY' | 'INCOME' | 'EXPENSE' | 'CONTINGENT';
export interface GlAccount {
code: string;
name: string;
type: GlAccountType;
/** Balance-sheet / P&L grouping used as a sub-heading in the reports. */
group: string;
/** Included in the Misc Expense Ledger report. */
miscExpense?: boolean;
}
export interface GlBranch {
code: string;
name: string;
}
/** One side of a double-entry voucher. */
export interface GlPosting {
/** ISO yyyy-MM-dd — the posting (value) date. */
date: string;
branchCode: string;
accountCode: string;
debit: number;
credit: number;
voucherNo: string;
narration: string;
}
export const GL_BRANCHES: GlBranch[] = [
{ code: 'HO', name: 'Head Office' },
{ code: '0002', name: 'Andheri (E)' },
{ code: '0003', name: 'Bandra (W)' },
{ code: '0004', name: 'Borivali' },
{ code: '0005', name: 'Dadar' },
{ code: '0006', name: 'Thane' }
];
export const GL_ACCOUNTS: GlAccount[] = [
// ── Assets ────────────────────────────────────────────────────────────────
{ code: '110101', name: 'Cash in Hand', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '110102', name: 'Balance with RBI', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '110103', name: 'Balance with Other Banks — Current', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '110104', name: 'Balance with Other Banks — Fixed', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '120101', name: 'SLR Investments — Govt Securities', type: 'ASSET', group: 'Investments' },
{ code: '120102', name: 'SLR Investments — State Govt Securities', type: 'ASSET', group: 'Investments' },
{ code: '120201', name: 'Non-SLR Investments — Bonds', type: 'ASSET', group: 'Investments' },
{ code: '120202', name: 'Non-SLR Investments — Mutual Funds', type: 'ASSET', group: 'Investments' },
{ code: '130101', name: 'Cash Credit & Overdrafts', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130102', name: 'Term Loans — Short Term', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130103', name: 'Term Loans — Medium Term', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130104', name: 'Term Loans — Long Term', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130105', name: 'Housing Loans', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130106', name: 'Gold Loans', type: 'ASSET', group: 'Loans & Advances' },
{ code: '140101', name: 'Interest Receivable', type: 'ASSET', group: 'Other Assets' },
{ code: '140102', name: 'Premises (net of depreciation)', type: 'ASSET', group: 'Fixed Assets' },
{ code: '140103', name: 'Furniture & Fixtures (net)', type: 'ASSET', group: 'Fixed Assets' },
{ code: '140104', name: 'Sundry Debtors', type: 'ASSET', group: 'Other Assets' },
{ code: '140105', name: 'Advance Tax & TDS', type: 'ASSET', group: 'Other Assets' },
{ code: '140199', name: 'Opening Balance Suspense (unreconciled)', type: 'ASSET', group: 'Other Assets' },
// ── Liabilities & Capital ─────────────────────────────────────────────────
{ code: '210101', name: 'Share Capital — Individuals', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210201', name: 'Statutory Reserve Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210202', name: 'Building Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210203', name: 'Investment Depreciation Reserve', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210204', name: 'BDDR / Special BDDR', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '220101', name: 'Savings Bank Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '220102', name: 'Current Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '220103', name: 'Term Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '220104', name: 'Recurring Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '230101', name: 'Interest Payable on Deposits', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230102', name: 'Bills Payable', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230103', name: 'Sundry Creditors', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230104', name: 'TDS / GST Payable', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230105', name: 'Overdue Interest Reserve', type: 'LIABILITY', group: 'Other Liabilities' },
// ── Income ────────────────────────────────────────────────────────────────
{ code: '310101', name: 'Interest on Loans & Advances', type: 'INCOME', group: 'Interest Income' },
{ code: '310102', name: 'Interest on Investments', type: 'INCOME', group: 'Interest Income' },
{ code: '310103', name: 'Interest on Inter-bank Deposits', type: 'INCOME', group: 'Interest Income' },
{ code: '320101', name: 'Commission & Exchange', type: 'INCOME', group: 'Other Income' },
{ code: '320102', name: 'Locker Rent', type: 'INCOME', group: 'Other Income' },
{ code: '320103', name: 'Processing Fees', type: 'INCOME', group: 'Other Income' },
{ code: '320104', name: 'Profit on Sale of Securities', type: 'INCOME', group: 'Other Income' },
{ code: '320105', name: 'Miscellaneous Income', type: 'INCOME', group: 'Other Income' },
// ── Expenditure ───────────────────────────────────────────────────────────
{ code: '410101', name: 'Interest on Savings Deposits', type: 'EXPENSE', group: 'Interest Expended' },
{ code: '410102', name: 'Interest on Term Deposits', type: 'EXPENSE', group: 'Interest Expended' },
{ code: '410103', name: 'Interest on Borrowings', type: 'EXPENSE', group: 'Interest Expended' },
{ code: '420101', name: 'Salaries & Allowances', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420102', name: "Directors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
{ 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: '430111', name: 'Electricity Charges', 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' },
{ code: '910102', name: 'Guarantees Issued — Performance', type: 'CONTINGENT', group: 'Guarantees' },
{ code: '910103', name: 'Letters of Credit Outstanding', type: 'CONTINGENT', group: 'Letters of Credit' },
{ code: '910104', name: 'Bills for Collection', type: 'CONTINGENT', group: 'Bills & Claims' },
{ code: '910105', name: 'Claims Against Bank Not Acknowledged as Debt', type: 'CONTINGENT', group: 'Bills & Claims' },
{ code: '910106', name: 'Forward Exchange Contracts', type: 'CONTINGENT', group: 'Other Commitments' },
{ code: '910107', name: 'Capital Commitments Not Provided For', type: 'CONTINGENT', group: 'Other Commitments' }
];
/** Financial year covered by the generated ledger. */
export const GL_PERIOD = {
/** FY 2026-27 opens here. */
fyStart: '2026-04-01',
/** Postings run to the day before the app's "today" (01 Aug 2026). */
fyEnd: '2026-07-31',
label: 'FY 2026-27'
};
/**
* Opening balances as at 01-Apr-2026, keyed by account code. Positive means the
* account opens on its natural side (debit for assets/expenses, credit for the
* rest). Head Office carries the investments and reserves; branches carry the
* customer-facing book.
*/
const OPENING_BALANCES: Record<string, number> = {
'110101': 9_996_000,
'110102': 12_400_000,
'110103': 13_667_000,
// Surplus deposits parked with correspondent banks. Sized so the opening
// balance sheet tallies exactly — see the assertion in glPostings().
'110104': 54_206_000,
'120101': 63_839_000,
'120102': 147_895_000,
'120201': 22_500_000,
'120202': 24_234_000,
'130101': 166_614_000,
'130102': 125_082_000,
'130103': 70_236_000,
'130104': 29_356_000,
'130105': 10_241_000,
'130106': 8_450_000,
'140101': 33_541_000,
'140102': 3_185_000,
'140103': 1_763_000,
'140104': 1_240_000,
'140105': 657_000,
'210101': 27_278_000,
'210201': 31_149_000,
'210202': 13_642_000,
'210203': 3_750_000,
'210204': 24_341_000,
'220101': 208_116_000,
'220102': 19_592_000,
'220103': 406_601_000,
'220104': 24_880_000,
'230101': 731_000,
'230102': 291_000,
'230103': 5_515_000,
'230104': 1_120_000,
'230105': 32_096_000,
'910101': 1_450_000,
'910102': 980_000,
'910103': 620_000,
'910104': 1_180_000,
'910105': 2_638_000,
'910106': 0,
'910107': 340_000
};
/** 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 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}`;
}
/** Every posting date in the ledger period, Sundays excluded (bank holiday). */
export function ledgerDates(): string[] {
const out: string[] = [];
const cur = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
while (cur <= end) {
if (cur.getUTCDay() !== 0) {
out.push(isoDate(cur));
}
cur.setUTCDate(cur.getUTCDate() + 1);
}
return out;
}
/** Templates for the vouchers the generator posts each day. */
const VOUCHER_TEMPLATES: {
debit: string;
credit: string;
narration: string;
/** Rough rupee scale of the voucher. */
scale: number;
/** Chance the voucher is raised on any given branch-day. */
frequency: number;
}[] = [
{ debit: '110101', credit: '220101', narration: 'Cash deposit — savings accounts', scale: 900_000, frequency: 1 },
{ debit: '220101', credit: '110101', narration: 'Cash withdrawal — savings accounts', scale: 780_000, frequency: 1 },
{ debit: '110101', credit: '220103', narration: 'Term deposit received', scale: 1_400_000, frequency: 0.75 },
{ debit: '220103', credit: '110101', narration: 'Term deposit matured and paid', scale: 1_150_000, frequency: 0.6 },
{ debit: '110101', credit: '220102', narration: 'Current account deposit', scale: 620_000, frequency: 0.8 },
{ debit: '220102', credit: '110101', narration: 'Current account withdrawal', scale: 560_000, frequency: 0.8 },
{ debit: '110101', credit: '220104', narration: 'Recurring deposit instalment', scale: 180_000, frequency: 0.7 },
{ debit: '130101', credit: '110101', narration: 'Cash credit disbursed', scale: 1_250_000, frequency: 0.55 },
{ debit: '110101', credit: '130101', narration: 'Cash credit repayment', scale: 1_100_000, frequency: 0.6 },
{ debit: '130102', credit: '110101', narration: 'Short term loan disbursed', scale: 850_000, frequency: 0.4 },
{ debit: '110101', credit: '130102', narration: 'Short term loan repayment', scale: 760_000, frequency: 0.5 },
{ debit: '130103', credit: '110101', narration: 'Medium term loan disbursed', scale: 640_000, frequency: 0.3 },
{ debit: '110101', credit: '130104', narration: 'Long term loan repayment', scale: 410_000, frequency: 0.35 },
{ debit: '130105', credit: '110101', narration: 'Housing loan disbursed', scale: 520_000, frequency: 0.2 },
{ debit: '130106', credit: '110101', narration: 'Gold loan disbursed', scale: 260_000, frequency: 0.4 },
// Interest on advances is the dominant earning line — sized so the bank runs a
// ~13% net margin rather than the loss the first-cut figures produced.
{ debit: '110101', credit: '310101', narration: 'Interest recovered on advances', scale: 430_000, frequency: 1 },
{ debit: '140101', credit: '310102', narration: 'Interest accrued on investments', scale: 148_000, frequency: 0.5 },
{ debit: '110103', credit: '310103', narration: 'Interest on inter-bank deposits', scale: 42_000, frequency: 0.25 },
{ debit: '110101', credit: '320101', narration: 'Commission and exchange collected', scale: 26_000, frequency: 0.9 },
{ 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: '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 },
{ debit: '430102', credit: '110101', narration: 'Postage and telephone charges', scale: 6_200, frequency: 0.35 },
{ debit: '430103', credit: '110101', narration: 'Advertisement and publicity', scale: 12_500, frequency: 0.15 },
{ debit: '430104', credit: '110101', narration: 'Repairs and maintenance', scale: 15_800, frequency: 0.25 },
{ debit: '430105', credit: '110101', narration: 'Travelling and conveyance', scale: 7_100, frequency: 0.3 },
{ debit: '430106', credit: '110101', narration: 'Computer and software expenses', scale: 22_400, frequency: 0.2 },
{ debit: '430107', credit: '110101', narration: 'Legal and professional charges', scale: 11_300, frequency: 0.15 },
{ 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 }
];
/** 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: '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 },
{ debit: '420105', credit: '140103', narration: 'Depreciation charged on assets', scale: 64_000, frequency: 0.12 },
{ debit: '430108', credit: '110101', narration: 'DICGC premium and other insurance', scale: 38_000, frequency: 0.1 },
{ debit: '430110', credit: '110101', narration: 'AGM and meeting expenses', scale: 26_000, frequency: 0.06 },
{ debit: '410103', credit: '230101', narration: 'Interest on borrowings', scale: 54_000, frequency: 0.1 },
{ debit: '230104', credit: '110102', narration: 'TDS and GST remitted', scale: 145_000, frequency: 0.15 }
];
/**
* Contingent-account movements are memoranda, each posted single-sided.
* Guarantees, LCs and collection bills are raised at the counter, so these run
* at every branch — not just Head Office.
*/
const CONTINGENT_TEMPLATES: { account: string; narration: string; scale: number; frequency: number }[] = [
{ account: '910101', narration: 'Financial guarantee issued', scale: 320_000, frequency: 0.1 },
{ account: '910102', narration: 'Performance guarantee issued', scale: 240_000, frequency: 0.08 },
{ account: '910103', narration: 'Letter of credit opened', scale: 180_000, frequency: 0.07 },
{ account: '910104', narration: 'Bills lodged for collection', scale: 95_000, frequency: 0.12 },
{ account: '910105', narration: 'Claim lodged against the bank', scale: 140_000, frequency: 0.03 },
{ account: '910107', narration: 'Capital commitment contracted', scale: 85_000, frequency: 0.04 }
];
/**
* How the opening contingent book is spread across the network. Head Office
* carries the capital commitments and large claims; the branches carry the
* counter-issued guarantees. Weights are applied per account and the rounding
* remainder is absorbed by Head Office so each account's total stays exact.
*/
const CONTINGENT_BRANCH_WEIGHTS: Record<string, number> = {
HO: 0.2,
'0002': 0.18,
'0003': 0.16,
'0004': 0.16,
'0005': 0.15,
'0006': 0.15
};
let cachedPostings: GlPosting[] | null = null;
/**
* The full generated ledger. Built once and memoised — every report reads from
* this array, so the reports can never disagree with one another.
*/
export function glPostings(): GlPosting[] {
if (cachedPostings) {
return cachedPostings;
}
const rand = lcg(20260401);
const out: GlPosting[] = [];
const dates = ledgerDates();
let voucherSeq = 1;
// Opening entries, posted at Head Office on day one so the books start in balance.
const openingVoucher = `OB/${GL_PERIOD.label.replace(/\s/g, '')}`;
let openingDebit = 0;
let openingCredit = 0;
for (const [code, amount] of Object.entries(OPENING_BALANCES)) {
if (!amount) {
continue;
}
const account = GL_ACCOUNTS.find((a) => a.code === code);
if (!account) {
continue;
}
const debitSide = account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
// Contingent accounts are single-sided memoranda held at the issuing branch,
// so their opening book is spread across the network rather than sat at HO.
if (account.type === 'CONTINGENT') {
let allocated = 0;
for (const branch of GL_BRANCHES) {
if (branch.code === 'HO') {
continue;
}
const share = Math.round((amount * (CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0)) / 100) * 100;
if (!share) {
continue;
}
allocated += share;
out.push({
date: GL_PERIOD.fyStart,
branchCode: branch.code,
accountCode: code,
debit: share,
credit: 0,
voucherNo: openingVoucher,
narration: 'Opening balance brought forward'
});
}
// Head Office takes its own share plus whatever rounding left behind.
out.push({
date: GL_PERIOD.fyStart,
branchCode: 'HO',
accountCode: code,
debit: amount - allocated,
credit: 0,
voucherNo: openingVoucher,
narration: 'Opening balance brought forward'
});
continue;
}
out.push({
date: GL_PERIOD.fyStart,
branchCode: 'HO',
accountCode: code,
debit: debitSide ? amount : 0,
credit: debitSide ? 0 : amount,
voucherNo: openingVoucher,
narration: 'Opening balance brought forward'
});
if (debitSide) {
openingDebit += amount;
} else {
openingCredit += amount;
}
}
// If OPENING_BALANCES is ever edited so the two sides diverge, park the residual
// in a visibly-named suspense account rather than letting the trial balance drift.
const openingResidual = openingCredit - openingDebit;
if (openingResidual !== 0) {
out.push({
date: GL_PERIOD.fyStart,
branchCode: 'HO',
accountCode: '140199',
debit: openingResidual > 0 ? openingResidual : 0,
credit: openingResidual < 0 ? -openingResidual : 0,
voucherNo: openingVoucher,
narration: 'Unreconciled opening balance difference'
});
}
const round = (n: number): number => Math.round(n / 100) * 100;
for (const date of dates) {
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;
for (const tpl of templates) {
if (rand() > tpl.frequency * weight) {
continue;
}
// 0.55x .. 1.45x of the template scale keeps daily figures varied but plausible.
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
if (amount <= 0) {
continue;
}
const voucherNo = `${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`;
out.push({
date,
branchCode: branch.code,
accountCode: tpl.debit,
debit: amount,
credit: 0,
voucherNo,
narration: tpl.narration
});
out.push({
date,
branchCode: branch.code,
accountCode: tpl.credit,
debit: 0,
credit: amount,
voucherNo,
narration: tpl.narration
});
}
// Contingent memoranda are raised at every branch, weighted by its size.
const contingentWeight = CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0.15;
for (const tpl of CONTINGENT_TEMPLATES) {
// Claims against the bank and capital commitments are Head Office matters.
const headOfficeOnly = tpl.account === '910105' || tpl.account === '910107';
if (headOfficeOnly && branch.code !== 'HO') {
continue;
}
const frequency = headOfficeOnly ? tpl.frequency : tpl.frequency * (contingentWeight / 0.2);
if (rand() > frequency) {
continue;
}
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
out.push({
date,
branchCode: branch.code,
accountCode: tpl.account,
debit: amount,
credit: 0,
voucherNo: `CNT/${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`,
narration: tpl.narration
});
}
}
}
cachedPostings = out;
return out;
}
export function glAccount(code: string): GlAccount | undefined {
return GL_ACCOUNTS.find((a) => a.code === code);
}
export function glBranchName(code: string): string {
return GL_BRANCHES.find((b) => b.code === code)?.name ?? code;
}
@@ -0,0 +1,34 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
const routes: Routes = [
Route.withShell([
{
path: 'gl/reports',
// Lazy — the generated ledger and ExcelJS should not weigh down the eager bundle.
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
},
{
path: 'gl/reports/:report',
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class GlReportsRoutingModule {}
@@ -0,0 +1,523 @@
<!--
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="glr-root">
<!-- HEADER -->
<div class="glr-head">
<div class="glr-head-left">
<fa-icon icon="book-open" class="glr-head-ic"></fa-icon>
<div>
<h2 class="glr-head-t">General Ledger — {{ reportLabel }}</h2>
<div class="glr-head-s">
CBSNexora Bank Ltd &middot; {{ branchLabel }} &middot; Period <strong>{{ rangeLabel }}</strong> &middot;
{{ ledgerPeriod.label }}
</div>
</div>
</div>
<button class="glr-btn glr-btn-xls" [disabled]="generating" (click)="exportExcel()">
<fa-icon icon="file-csv"></fa-icon>
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
<!-- FILTER BAR -->
<div class="glr-filters">
<div class="glr-filter">
<label>Period</label>
<div class="glr-seg">
<button [class.active]="periodMode === 'day'" (click)="periodMode = 'day'">Day-wise</button>
<button [class.active]="periodMode === 'month'" (click)="periodMode = 'month'">Month-wise</button>
<button [class.active]="periodMode === 'range'" (click)="periodMode = 'range'">Date Range</button>
</div>
</div>
@if (periodMode === 'day') {
<div class="glr-filter">
<label>Date</label>
<input
class="glr-inp"
type="date"
[(ngModel)]="filterDay"
[min]="ledgerPeriod.fyStart"
[max]="ledgerPeriod.fyEnd"
/>
</div>
}
@if (periodMode === 'month') {
<div class="glr-filter">
<label>Month</label>
<input class="glr-inp" type="month" [(ngModel)]="filterMonth" min="2026-04" max="2026-07" />
</div>
}
@if (periodMode === 'range') {
<div class="glr-filter">
<label>From</label>
<input
class="glr-inp"
type="date"
[(ngModel)]="filterFrom"
[min]="ledgerPeriod.fyStart"
[max]="ledgerPeriod.fyEnd"
/>
</div>
<div class="glr-filter">
<label>To</label>
<input
class="glr-inp"
type="date"
[(ngModel)]="filterTo"
[min]="ledgerPeriod.fyStart"
[max]="ledgerPeriod.fyEnd"
/>
</div>
}
<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>
</div>
<button class="glr-btn" (click)="resetFilters()">Reset</button>
</div>
<!-- REPORT SELECTOR -->
<div class="glr-tabs">
@for (r of reports; track r.code) {
<button class="glr-tab" [class.active]="activeReport === r.code" (click)="showReport(r.code)">
<fa-icon [icon]="r.icon"></fa-icon> {{ r.label }}
</button>
}
</div>
<!-- ══════════════ TRIAL BALANCE ══════════════ -->
@if (activeReport === 'trial-balance') {
<div class="glr-section">
<div class="glr-balance" [class.glr-balance--bad]="!tbBalanced">
<fa-icon [icon]="tbBalanced ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span>
Closing Dr <strong>&#8377;{{ tbClosingDebit | number: '1.2-2' }}</strong> &middot; Closing Cr
<strong>&#8377;{{ tbClosingCredit | number: '1.2-2' }}</strong> —
{{ tbBalanced ? 'trial balance tallies' : 'OUT OF BALANCE — investigate before closing the period' }}
</span>
</div>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
<th class="ta-r">Opening Dr</th>
<th class="ta-r">Opening Cr</th>
<th class="ta-r">Period Dr</th>
<th class="ta-r">Period Cr</th>
<th class="ta-r">Closing Dr</th>
<th class="ta-r">Closing Cr</th>
</tr>
</thead>
<tbody>
@for (r of data.trialBalance; 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.openingDebit ? (r.openingDebit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.openingCredit ? (r.openingCredit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.periodDebit ? (r.periodDebit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.periodCredit ? (r.periodCredit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.closingDebit ? (r.closingDebit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.closingCredit ? (r.closingCredit | number: '1.2-2') : '' }}</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="glr-empty">No ledger activity for this period and branch.</td>
</tr>
}
<tr class="total-row">
<td colspan="3">TOTAL ({{ data.trialBalance.length }} accounts)</td>
<td class="ta-r mono">{{ tbOpeningDebit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ tbOpeningCredit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ tbPeriodDebit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ tbPeriodCredit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ tbClosingDebit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ tbClosingCredit | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ PROFIT AND LOSS ══════════════ -->
@if (activeReport === 'profit-and-loss') {
<div class="glr-section">
<div class="glr-kpis">
<div class="glr-kpi glr-kpi--teal">
<div class="glr-kpi-n">&#8377;{{ totalIncome | number: '1.2-2' }}</div>
<div class="glr-kpi-l">Total Income</div>
</div>
<div class="glr-kpi glr-kpi--amber">
<div class="glr-kpi-n">&#8377;{{ totalExpense | number: '1.2-2' }}</div>
<div class="glr-kpi-l">Total Expenditure</div>
</div>
<div class="glr-kpi" [class.glr-kpi--green]="netProfit >= 0" [class.glr-kpi--red]="netProfit < 0">
<div class="glr-kpi-n">&#8377;{{ netProfit | number: '1.2-2' }}</div>
<div class="glr-kpi-l">{{ netProfit >= 0 ? 'Net Profit' : 'Net Loss' }}</div>
<div class="glr-kpi-sub">{{ netProfitPct | number: '1.2-2' }}% of income</div>
</div>
</div>
<div class="glr-two-col">
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Expenditure</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@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 class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="glr-empty">No expenditure posted in this period.</td>
</tr>
}
<tr class="total-row">
<td colspan="2">Total Expenditure</td>
<td class="ta-r mono">{{ totalExpense | number: '1.2-2' }}</td>
</tr>
@if (netProfit >= 0) {
<tr class="total-row glr-profit-row">
<td colspan="2">Net Profit carried to Balance Sheet</td>
<td class="ta-r mono">{{ netProfit | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Income</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@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 class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="glr-empty">No income posted in this period.</td>
</tr>
}
<tr class="total-row">
<td colspan="2">Total Income</td>
<td class="ta-r mono">{{ totalIncome | number: '1.2-2' }}</td>
</tr>
@if (netProfit < 0) {
<tr class="total-row glr-loss-row">
<td colspan="2">Net Loss carried to Balance Sheet</td>
<td class="ta-r mono">{{ -netProfit | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
<!-- ══════════════ CONSOLIDATED BALANCE SHEET ══════════════ -->
@if (activeReport === 'balance-sheet') {
<div class="glr-section">
<div class="glr-balance" [class.glr-balance--bad]="!bsBalanced">
<fa-icon [icon]="bsBalanced ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span>
Total Assets <strong>&#8377;{{ bsTotalAssets | number: '1.2-2' }}</strong> &middot; Total Liabilities
<strong>&#8377;{{ bsTotalLiabilities | number: '1.2-2' }}</strong> —
{{ bsBalanced ? 'balance sheet tallies' : 'OUT OF BALANCE' }}
</span>
</div>
<div class="glr-two-col">
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Capital &amp; Liabilities &mdash; as at {{ range.to }}</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@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 class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
<tr>
<td class="mono">—</td>
<td>Surplus for the period<span class="muted"> · Profit &amp; Loss</span></td>
<td class="ta-r mono">{{ bsSurplus | number: '1.2-2' }}</td>
</tr>
<tr class="total-row">
<td colspan="2">Total Capital &amp; Liabilities</td>
<td class="ta-r mono">{{ bsTotalLiabilities | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Assets &mdash; as at {{ range.to }}</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@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 class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="total-row">
<td colspan="2">Total Assets</td>
<td class="ta-r mono">{{ bsTotalAssets | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
}
<!-- ══════════════ COMPARATIVE BALANCE SHEET ══════════════ -->
@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.
</p>
@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">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th class="ta-r">Current (&#8377;)</th>
<th class="ta-r">Previous (&#8377;)</th>
<th class="ta-r">Variance (&#8377;)</th>
<th class="ta-r">Variance %</th>
</tr>
</thead>
<tbody>
@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 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">
{{ r.variance | number: '1.2-2' }}
</td>
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
{{ r.variancePct === null ? '—' : (r.variancePct | number: '1.2-2') + '%' }}
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="glr-empty">Nothing to compare for this period and branch.</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- ══════════════ CONTINGENT LIABILITIES ══════════════ -->
@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.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th>Group</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (r of data.contingent; 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.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="glr-empty">No contingent exposures outstanding for this selection.</td>
</tr>
}
<tr class="total-row">
<td colspan="3">Total Contingent Liabilities</td>
<td class="ta-r mono">{{ contingentTotal | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ GL WISE DR / CR ══════════════ -->
@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.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Type</th>
<th>Group</th>
<th class="ta-r">Debit (&#8377;)</th>
<th class="ta-r">Credit (&#8377;)</th>
<th class="ta-r">Net (Dr &minus; Cr)</th>
<th class="ta-c">Vouchers</th>
</tr>
</thead>
<tbody>
@for (r of data.glWise; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td>
<span class="type-pill" [class]="'type-' + r.type.toLowerCase()">{{ r.type }}</span>
</td>
<td class="muted">{{ r.group }}</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" [class.var-up]="r.net > 0" [class.var-down]="r.net < 0">
{{ r.net | number: '1.2-2' }}
</td>
<td class="ta-c mono">{{ r.vouchers }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="glr-empty">No postings for this period and branch.</td>
</tr>
}
<tr class="total-row">
<td colspan="4">TOTAL ({{ data.glWise.length }} accounts)</td>
<td class="ta-r mono">{{ glWiseDebit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ glWiseCredit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ glWiseDebit - glWiseCredit | number: '1.2-2' }}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ MISC EXPENSE LEDGER ══════════════ -->
@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.
</p>
<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>
</tr>
</thead>
<tbody>
@for (r of miscRows; track r.voucherNo + r.code) {
<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>
</tr>
} @empty {
<tr>
<td colspan="7" class="glr-empty">No miscellaneous expenses posted for this selection.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="6">Total Miscellaneous Expenses</td>
<td class="ta-r mono">{{ miscTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- TOAST -->
<div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -0,0 +1,506 @@
/**
* 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 {
--g-navy: #1a237e;
--g-blue: #1d4ed8;
--g-blue-l: #eff6ff;
--g-teal: #0e7e6b;
--g-teal-l: #e0f4f0;
--g-green: #15803d;
--g-green-l: #dcfce7;
--g-amber: #b45309;
--g-amber-l: #fef3c7;
--g-red: #c0392b;
--g-red-l: #fdecea;
--g-g50: #f8fafc;
--g-g100: #f1f5f9;
--g-g200: #e2e8f0;
--g-g300: #cbd5e1;
--g-g500: #64748b;
--g-g700: #334155;
--g-g900: #0f172a;
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
background: var(--g-g50);
color: var(--g-g900);
font-size: 14px;
line-height: 1.5;
}
.glr-root {
padding: 18px 22px;
max-width: 1700px;
margin: 0 auto;
}
/* HEADER */
.glr-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.glr-head-left {
display: flex;
align-items: center;
gap: 14px;
}
.glr-head-ic {
font-size: 28px;
color: var(--g-navy);
}
.glr-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.glr-head-s {
font-size: 12px;
color: var(--g-g500);
}
/* BUTTONS */
.glr-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border: 1px solid var(--g-g300);
background: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--g-g700);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
white-space: nowrap;
&:hover:not(:disabled) {
background: var(--g-g50);
border-color: var(--g-g500);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.glr-btn-xls {
background: #16a34a;
border-color: #16a34a;
color: #fff;
&:hover:not(:disabled) {
background: #15803d;
border-color: #15803d;
color: #fff;
}
}
/* FILTER BAR */
.glr-filters {
background: #fff;
border: 1px solid var(--g-g200);
border-radius: 12px;
padding: 14px;
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
}
.glr-filter {
display: flex;
flex-direction: column;
gap: 5px;
label {
font-size: 11px;
font-weight: 700;
color: var(--g-g500);
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.glr-inp {
padding: 8px 12px;
border: 1px solid var(--g-g200);
border-radius: 8px;
font-size: 13px;
background: #fff;
font-family: inherit;
min-width: 190px;
&:focus {
outline: none;
border-color: var(--g-navy);
box-shadow: 0 0 0 3px rgb(26 35 126 / 12%);
}
}
/* Day / Month / Range segmented control */
.glr-seg {
display: inline-flex;
border: 1px solid var(--g-g200);
border-radius: 8px;
overflow: hidden;
button {
padding: 8px 14px;
border: none;
background: #fff;
font-size: 13px;
font-weight: 600;
color: var(--g-g500);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
+ button {
border-left: 1px solid var(--g-g200);
}
&:hover {
background: var(--g-g50);
}
&.active {
background: var(--g-navy);
color: #fff;
}
}
}
/* REPORT SELECTOR */
.glr-tabs {
display: flex;
gap: 4px;
background: #fff;
border: 1px solid var(--g-g200);
border-radius: 10px;
padding: 4px;
margin-bottom: 16px;
overflow-x: auto;
}
.glr-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border: none;
background: transparent;
font-size: 13px;
font-weight: 500;
color: var(--g-g500);
cursor: pointer;
border-radius: 7px;
white-space: nowrap;
transition: all 0.15s;
font-family: inherit;
&:hover {
color: var(--g-g900);
}
&.active {
background: var(--g-navy);
color: #fff;
box-shadow: 0 2px 6px rgb(26 35 126 / 25%);
}
}
.glr-section {
display: flex;
flex-direction: column;
gap: 16px;
}
/* BALANCE BANNER */
.glr-balance {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: 10px;
background: var(--g-green-l);
border: 1px solid #86efac;
color: #14532d;
font-size: 13px;
&--bad {
background: var(--g-red-l);
border-color: #fca5a5;
color: #7f1d1d;
}
}
.glr-note {
margin: 0;
font-size: 12px;
color: var(--g-g500);
}
/* KPIs */
.glr-kpis {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.glr-kpi {
background: #fff;
border: 1px solid var(--g-g200);
border-left: 4px solid var(--g-g300);
border-radius: 12px;
padding: 14px 18px;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
&--teal {
border-left-color: var(--g-teal);
}
&--amber {
border-left-color: var(--g-amber);
}
&--green {
border-left-color: var(--g-green);
}
&--red {
border-left-color: var(--g-red);
}
}
.glr-kpi-n {
font-size: 20px;
font-weight: 700;
font-family: 'DM Mono', monospace;
color: var(--g-g900);
}
.glr-kpi-l {
font-size: 12px;
color: var(--g-g700);
font-weight: 600;
margin-top: 2px;
}
.glr-kpi-sub {
font-size: 11px;
color: var(--g-g500);
}
/* TABLES */
.glr-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.glr-tbl-wrap {
background: #fff;
border: 1px solid var(--g-g200);
border-radius: 12px;
overflow: auto;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
&--tall {
max-height: 70vh;
}
}
.glr-tbl-h {
background: var(--g-blue-l);
color: var(--g-navy);
padding: 10px 16px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--g-g200);
position: sticky;
top: 0;
z-index: 2;
}
.glr-tbl {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
padding: 9px 12px;
font-size: 11px;
font-weight: 700;
color: var(--g-g500);
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--g-g50);
border-bottom: 1px solid var(--g-g200);
white-space: nowrap;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--g-g100);
font-size: 13px;
color: var(--g-g700);
}
tbody tr:hover td {
background: var(--g-g50);
}
tfoot td {
position: sticky;
bottom: 0;
}
}
.total-row td {
background: var(--g-g100) !important;
font-weight: 700;
color: var(--g-g900);
border-top: 2px solid var(--g-g300);
}
.glr-profit-row td {
background: var(--g-green-l) !important;
color: #14532d;
}
.glr-loss-row td {
background: var(--g-red-l) !important;
color: #7f1d1d;
}
.ta-c {
text-align: center;
}
.ta-r {
text-align: right;
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 12px;
}
.muted {
color: var(--g-g500);
font-size: 11px;
}
.var-up {
color: var(--g-green);
font-weight: 600;
}
.var-down {
color: var(--g-red);
font-weight: 600;
}
.glr-empty {
text-align: center;
padding: 34px;
color: var(--g-g500);
font-style: italic;
}
/* Account-type pills on the GL Wise report */
.type-pill {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
background: var(--g-g100);
color: var(--g-g700);
&.type-asset {
background: var(--g-blue-l);
color: var(--g-blue);
}
&.type-liability {
background: var(--g-teal-l);
color: var(--g-teal);
}
&.type-income {
background: var(--g-green-l);
color: var(--g-green);
}
&.type-expense {
background: var(--g-amber-l);
color: var(--g-amber);
}
&.type-contingent {
background: var(--g-red-l);
color: var(--g-red);
}
}
/* TOAST */
.glr-toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--g-navy);
color: #fff;
padding: 12px 22px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
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) {
.glr-two-col {
grid-template-columns: 1fr;
}
.glr-kpis {
grid-template-columns: 1fr;
}
}
+470
View File
@@ -0,0 +1,470 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
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 {
BalanceSheetRow,
ComparativeRow,
GlDateRange,
GlReportSet,
MiscExpenseRow,
PnlRow,
buildReports,
clampToLedger,
sum
} from './gl-reports.compute';
export type GlReportCode =
| 'trial-balance'
| 'profit-and-loss'
| 'balance-sheet'
| 'comparative-balance-sheet'
| 'contingent-liabilities'
| 'gl-wise-dr-cr'
| 'misc-expense-ledger';
type PeriodMode = 'day' | 'month' | 'range';
@Component({
selector: 'mifosx-gl-reports',
templateUrl: './gl-reports.component.html',
styleUrls: ['./gl-reports.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class GlReportsComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' },
{ code: 'profit-and-loss', label: 'Profit and Loss', icon: 'chart-line' },
{ code: 'balance-sheet', label: 'Consolidated Balance Sheet', icon: 'file-alt' },
{ 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' }
];
activeReport: GlReportCode = 'trial-balance';
branches = GL_BRANCHES;
ledgerPeriod = GL_PERIOD;
// ── Filter state ───────────────────────────────────────────────────────────
periodMode: PeriodMode = 'month';
/** Last full month of the generated ledger. */
filterDay = '2026-07-31';
filterMonth = '2026-07';
filterFrom = GL_PERIOD.fyStart;
filterTo = GL_PERIOD.fyEnd;
branchCode = '';
generating = false;
toastMsg = '';
toastVisible = false;
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;
}
}
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 });
}
get reportLabel(): string {
return this.reports.find((r) => r.code === this.activeReport)?.label ?? '';
}
// ── Filter resolution ──────────────────────────────────────────────────────
/** Turns the filter controls into the concrete date range the reports use. */
get range(): GlDateRange {
if (this.periodMode === 'day') {
return clampToLedger({ from: this.filterDay, to: this.filterDay });
}
if (this.periodMode === 'month') {
const [
y,
m
] = this.filterMonth.split('-').map(Number);
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
return clampToLedger({ from: `${this.filterMonth}-01`, to: `${this.filterMonth}-${`${last}`.padStart(2, '0')}` });
}
return clampToLedger({ 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 branchLabel(): string {
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
}
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 = '';
}
/** All seven reports for the current filter — memoised inside buildReports. */
get data(): GlReportSet {
return buildReports(this.range, this.branchCode);
}
// ── Trial Balance ──────────────────────────────────────────────────────────
get tbOpeningDebit(): number {
return sum(this.data.trialBalance, (r) => r.openingDebit);
}
get tbOpeningCredit(): number {
return sum(this.data.trialBalance, (r) => r.openingCredit);
}
get tbPeriodDebit(): number {
return sum(this.data.trialBalance, (r) => r.periodDebit);
}
get tbPeriodCredit(): number {
return sum(this.data.trialBalance, (r) => r.periodCredit);
}
get tbClosingDebit(): number {
return sum(this.data.trialBalance, (r) => r.closingDebit);
}
get tbClosingCredit(): number {
return sum(this.data.trialBalance, (r) => r.closingCredit);
}
/** A trial balance that does not tally is the single most important thing to surface. */
get tbBalanced(): boolean {
return Math.round(this.tbClosingDebit) === Math.round(this.tbClosingCredit);
}
// ── Profit and Loss ────────────────────────────────────────────────────────
get pnlIncome(): PnlRow[] {
return this.data.pnl.income;
}
get pnlExpense(): PnlRow[] {
return this.data.pnl.expense;
}
get totalIncome(): number {
return sum(this.pnlIncome, (r) => r.amount);
}
get totalExpense(): number {
return sum(this.pnlExpense, (r) => r.amount);
}
get netProfit(): number {
return this.totalIncome - this.totalExpense;
}
get netProfitPct(): number {
return this.totalIncome ? (this.netProfit / this.totalIncome) * 100 : 0;
}
// ── Balance Sheet ──────────────────────────────────────────────────────────
get bsAssets(): BalanceSheetRow[] {
return this.data.balanceSheet.assets;
}
get bsLiabilities(): BalanceSheetRow[] {
return this.data.balanceSheet.liabilities;
}
get bsSurplus(): number {
return this.data.balanceSheet.surplus;
}
get bsTotalAssets(): number {
return sum(this.bsAssets, (r) => r.amount);
}
get bsTotalLiabilities(): number {
return sum(this.bsLiabilities, (r) => r.amount) + this.bsSurplus;
}
get bsBalanced(): boolean {
return Math.round(this.bsTotalAssets) === Math.round(this.bsTotalLiabilities);
}
// ── Comparative Balance Sheet ──────────────────────────────────────────────
get compAssets(): ComparativeRow[] {
return this.data.comparative.filter((r) => r.side === 'ASSET');
}
get compLiabilities(): ComparativeRow[] {
return this.data.comparative.filter((r) => r.side === 'LIABILITY');
}
// ── Contingent Liabilities ─────────────────────────────────────────────────
get contingentTotal(): number {
return sum(this.data.contingent, (r) => r.amount);
}
// ── GL Wise DR/CR ──────────────────────────────────────────────────────────
get glWiseDebit(): number {
return sum(this.data.glWise, (r) => r.debit);
}
get glWiseCredit(): number {
return sum(this.data.glWise, (r) => r.credit);
}
// ── Misc Expense Ledger ────────────────────────────────────────────────────
get miscRows(): MiscExpenseRow[] {
return this.data.miscExpense;
}
get miscTotal(): number {
return sum(this.miscRows, (r) => r.amount);
}
branchName(code: string): string {
return glBranchName(code);
}
// ── Excel export ───────────────────────────────────────────────────────────
/** Exports whichever report is on screen, carrying the active filter into the header. */
async exportExcel(): Promise<void> {
this.generating = true;
try {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBSNexora';
wb.created = new Date();
const sheet = wb.addWorksheet(this.reportLabel.slice(0, 31));
const { headers, rows, widths } = this.exportShape();
sheet.columns = widths.map((w) => ({ width: w }));
const title = sheet.addRow([this.reportLabel]);
sheet.mergeCells(title.number, 1, title.number, headers.length);
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
title.alignment = { horizontal: 'center', vertical: 'middle' };
title.height = 24;
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1A237E' } };
const meta = sheet.addRow([`Period: ${this.rangeLabel} · Branch: ${this.branchLabel}`]);
sheet.mergeCells(meta.number, 1, meta.number, headers.length);
meta.font = { italic: true, size: 10 };
sheet.addRow([]);
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' } };
});
for (const r of rows) {
const row = sheet.addRow(r);
for (let c = 1; c <= headers.length; c++) {
if (typeof r[c - 1] === 'number') {
row.getCell(c).numFmt = '#,##0.00';
}
}
}
sheet.views = [{ state: 'frozen', ySplit: 4 }];
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 = `${this.reportLabel.replace(/[^\w]+/g, '-')}-${this.range.from}-to-${this.range.to}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.toast(`${this.reportLabel} exported for ${this.rangeLabel}`);
} catch {
this.toast('Could not generate the workbook — please retry');
} finally {
this.generating = false;
}
}
/** Header row, data rows and column widths for the active report. */
private exportShape(): { headers: string[]; rows: (string | number | null)[][]; widths: number[] } {
switch (this.activeReport) {
case 'trial-balance':
return {
headers: [
'GL Code',
'Account',
'Group',
'Opening Dr',
'Opening Cr',
'Period Dr',
'Period Cr',
'Closing Dr',
'Closing Cr'
],
widths: [12, 42, 26, 16, 16, 16, 16, 16, 16],
rows: [
...this.data.trialBalance.map((r) => [
r.code,
r.name,
r.group,
r.openingDebit,
r.openingCredit,
r.periodDebit,
r.periodCredit,
r.closingDebit,
r.closingCredit
]),
[
'TOTAL',
'',
'',
this.tbOpeningDebit,
this.tbOpeningCredit,
this.tbPeriodDebit,
this.tbPeriodCredit,
this.tbClosingDebit,
this.tbClosingCredit
]
]
};
case 'profit-and-loss':
return {
headers: [
'Side',
'GL Code',
'Account',
'Group',
'Amount'
],
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]
]
};
case 'balance-sheet':
return {
headers: [
'Side',
'GL Code',
'Account',
'Group',
'Amount'
],
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]
]
};
case 'comparative-balance-sheet':
return {
headers: [
'Side',
'GL Code',
'Account',
'Current',
'Previous',
'Variance',
'Variance %'
],
widths: [12, 12, 42, 18, 18, 18, 14],
rows: this.data.comparative.map((r) => [
r.side,
r.code,
r.name,
r.current,
r.previous,
r.variance,
r.variancePct === null ? '—' : Number(r.variancePct.toFixed(2))
])
};
case 'contingent-liabilities':
return {
headers: [
'GL Code',
'Particulars',
'Group',
'Amount'
],
widths: [12, 48, 26, 18],
rows: [
...this.data.contingent.map((r) => [r.code, r.name, r.group, r.amount]),
['TOTAL', '', '', this.contingentTotal]
]
};
case 'gl-wise-dr-cr':
return {
headers: [
'GL Code',
'Account',
'Type',
'Group',
'Debit',
'Credit',
'Net (Dr-Cr)',
'Vouchers'
],
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]
]
};
case 'misc-expense-ledger':
default:
return {
headers: [
'Date',
'Voucher No',
'Branch',
'GL Code',
'Account',
'Narration',
'Amount'
],
widths: [14, 20, 18, 12, 36, 46, 18],
rows: [
...this.miscRows.map((r) => [
r.date,
r.voucherNo,
this.branchName(r.branchCode),
r.code,
r.name,
r.narration,
r.amount
]),
['TOTAL', '', '', '', '', '', this.miscTotal]
]
};
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
+379
View File
@@ -0,0 +1,379 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Derives all seven General Ledger reports from the posting ledger.
*
* Each report is a pure function of (postings, date range, branch), so changing
* the day / month / branch filter genuinely recomputes the figures. Results are
* memoised per filter key because the ledger runs to roughly twenty thousand
* postings and Angular re-evaluates template getters on every change detection.
*/
import {
GL_ACCOUNTS,
GL_PERIOD,
GlAccount,
GlPosting,
glAccount,
glPostings
} from './gl-reports-data';
export interface GlDateRange {
from: string;
to: string;
}
export interface TrialBalanceRow {
code: string;
name: string;
group: string;
openingDebit: number;
openingCredit: number;
periodDebit: number;
periodCredit: number;
closingDebit: number;
closingCredit: number;
}
export interface PnlRow {
code: string;
name: string;
group: string;
amount: number;
}
export interface BalanceSheetRow {
code: string;
name: string;
group: string;
amount: number;
}
export interface ComparativeRow {
code: string;
name: string;
group: string;
side: 'ASSET' | 'LIABILITY';
current: number;
previous: number;
variance: number;
variancePct: number | null;
}
export interface GlWiseRow {
code: string;
name: string;
group: string;
type: string;
debit: number;
credit: number;
net: number;
vouchers: number;
}
export interface MiscExpenseRow {
date: string;
voucherNo: string;
branchCode: string;
code: string;
name: string;
narration: string;
amount: number;
}
/** Natural side of an account — assets and expenses sit on the debit side. */
function isDebitSide(account: GlAccount): boolean {
return account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
}
function matchesBranch(p: GlPosting, branchCode: string): boolean {
return !branchCode || p.branchCode === branchCode;
}
/** 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 out = new Map<string, number>();
for (const p of glPostings()) {
if (p.date > 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;
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
}
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> }> {
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)) {
continue;
}
let entry = out.get(p.accountCode);
if (!entry) {
entry = { debit: 0, credit: 0, vouchers: new Set<string>() };
out.set(p.accountCode, entry);
}
entry.debit += p.debit;
entry.credit += p.credit;
entry.vouchers.add(p.voucherNo);
}
return out;
}
/** The day before `date`, ISO formatted — used for opening balances. */
function previousDay(date: string): string {
const d = new Date(`${date}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
// ── Reports ──────────────────────────────────────────────────────────────────
export function trialBalance(range: GlDateRange, branchCode: string): TrialBalanceRow[] {
const opening = cumulativeBalances(previousDay(range.from), branchCode);
const turnover = periodTurnover(range, branchCode);
const rows: TrialBalanceRow[] = [];
for (const account of GL_ACCOUNTS) {
// Contingent accounts are memoranda and never appear in the trial balance.
if (account.type === 'CONTINGENT') {
continue;
}
const open = opening.get(account.code) ?? 0;
const move = turnover.get(account.code);
const periodDebit = move?.debit ?? 0;
const periodCredit = move?.credit ?? 0;
if (!open && !periodDebit && !periodCredit) {
continue;
}
const debitSide = isDebitSide(account);
const closingNatural = open + (debitSide ? periodDebit - periodCredit : periodCredit - periodDebit);
const closingSigned = debitSide ? closingNatural : -closingNatural;
rows.push({
code: account.code,
name: account.name,
group: account.group,
openingDebit: debitSide && open > 0 ? open : !debitSide && open < 0 ? -open : 0,
openingCredit: !debitSide && open > 0 ? open : debitSide && open < 0 ? -open : 0,
periodDebit,
periodCredit,
closingDebit: closingSigned > 0 ? closingSigned : 0,
closingCredit: closingSigned < 0 ? -closingSigned : 0
});
}
return rows;
}
export function profitAndLoss(range: GlDateRange, branchCode: string): { income: PnlRow[]; expense: PnlRow[] } {
const turnover = periodTurnover(range, branchCode);
const income: PnlRow[] = [];
const expense: PnlRow[] = [];
for (const account of GL_ACCOUNTS) {
if (account.type !== 'INCOME' && account.type !== 'EXPENSE') {
continue;
}
const move = turnover.get(account.code);
if (!move) {
continue;
}
const amount =
account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
if (!amount) {
continue;
}
const row: PnlRow = { code: account.code, name: account.name, group: account.group, amount };
(account.type === 'INCOME' ? income : expense).push(row);
}
return { income, expense };
}
/**
* 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): {
assets: BalanceSheetRow[];
liabilities: BalanceSheetRow[];
surplus: number;
} {
const closing = cumulativeBalances(range.to, branchCode);
const assets: BalanceSheetRow[] = [];
const liabilities: BalanceSheetRow[] = [];
let income = 0;
let expense = 0;
for (const account of GL_ACCOUNTS) {
const bal = closing.get(account.code) ?? 0;
if (account.type === 'INCOME') {
income += bal;
continue;
}
if (account.type === 'EXPENSE') {
expense += bal;
continue;
}
if (account.type === 'CONTINGENT' || !bal) {
continue;
}
const row: BalanceSheetRow = { code: account.code, name: account.name, group: account.group, amount: bal };
(account.type === 'ASSET' ? assets : liabilities).push(row);
}
return { assets, liabilities, surplus: income - expense };
}
/** Current period against the equivalent span immediately before it. */
export function comparativeBalanceSheet(range: GlDateRange, branchCode: string): ComparativeRow[] {
const priorTo = previousDay(range.from);
const current = cumulativeBalances(range.to, branchCode);
const previous = cumulativeBalances(priorTo, branchCode);
const rows: ComparativeRow[] = [];
for (const account of GL_ACCOUNTS) {
if (account.type !== 'ASSET' && account.type !== 'LIABILITY') {
continue;
}
const cur = current.get(account.code) ?? 0;
const prev = previous.get(account.code) ?? 0;
if (!cur && !prev) {
continue;
}
const variance = cur - prev;
rows.push({
code: account.code,
name: account.name,
group: account.group,
side: account.type,
current: cur,
previous: prev,
variance,
variancePct: prev ? (variance / Math.abs(prev)) * 100 : null
});
}
return rows;
}
export function contingentLiabilities(range: GlDateRange, branchCode: string): BalanceSheetRow[] {
const closing = cumulativeBalances(range.to, branchCode);
return GL_ACCOUNTS.filter((a) => a.type === 'CONTINGENT')
.map((a) => ({ code: a.code, name: a.name, group: a.group, amount: closing.get(a.code) ?? 0 }))
.filter((r) => r.amount !== 0);
}
export function glWiseDrCr(range: GlDateRange, branchCode: string): GlWiseRow[] {
const turnover = periodTurnover(range, branchCode);
const rows: GlWiseRow[] = [];
for (const account of GL_ACCOUNTS) {
const move = turnover.get(account.code);
if (!move || (!move.debit && !move.credit)) {
continue;
}
rows.push({
code: account.code,
name: account.name,
group: account.group,
type: account.type,
debit: move.debit,
credit: move.credit,
net: move.debit - move.credit,
vouchers: move.vouchers.size
});
}
return rows;
}
export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscExpenseRow[] {
const miscCodes = new Set(GL_ACCOUNTS.filter((a) => a.miscExpense).map((a) => a.code));
const rows: MiscExpenseRow[] = [];
for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
continue;
}
if (!miscCodes.has(p.accountCode) || !p.debit) {
continue;
}
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
});
}
return rows.sort((a, b) => (a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)));
}
// ── Memoisation ──────────────────────────────────────────────────────────────
export interface GlReportSet {
trialBalance: TrialBalanceRow[];
pnl: { income: PnlRow[]; expense: PnlRow[] };
balanceSheet: { assets: BalanceSheetRow[]; liabilities: BalanceSheetRow[]; surplus: number };
comparative: ComparativeRow[];
contingent: BalanceSheetRow[];
glWise: GlWiseRow[];
miscExpense: MiscExpenseRow[];
}
let cacheKey = '';
let cached: GlReportSet | null = null;
/** All seven reports for one filter selection, computed once per selection. */
export function buildReports(range: GlDateRange, branchCode: string): GlReportSet {
const key = `${range.from}|${range.to}|${branchCode}`;
if (cached && cacheKey === key) {
return cached;
}
cached = {
trialBalance: trialBalance(range, branchCode),
pnl: profitAndLoss(range, branchCode),
balanceSheet: balanceSheet(range, branchCode),
comparative: comparativeBalanceSheet(range, branchCode),
contingent: contingentLiabilities(range, branchCode),
glWise: glWiseDrCr(range, branchCode),
miscExpense: miscExpenseLedger(range, branchCode)
};
cacheKey = key;
return cached;
}
/** Clamp a range to the generated ledger so filters cannot query empty space. */
export function clampToLedger(range: GlDateRange): GlDateRange {
return {
from: range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from,
to: range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to
};
}
export function sum<T>(rows: T[], pick: (row: T) => number): number {
return rows.reduce((acc, r) => acc + pick(r), 0);
}
/** Groups rows by their `group` field, preserving chart-of-accounts order. */
export function groupBy<T extends { group: string }>(rows: T[]): { group: string; rows: T[]; total: number }[] {
const order: string[] = [];
const map = new Map<string, T[]>();
for (const r of rows) {
if (!map.has(r.group)) {
map.set(r.group, []);
order.push(r.group);
}
map.get(r.group)!.push(r);
}
return order.map((g) => ({ group: g, rows: map.get(g)!, total: 0 }));
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { GlReportsRoutingModule } from './gl-reports-routing.module';
@NgModule({
imports: [
SharedModule,
GlReportsRoutingModule
]
})
export class GlReportsModule {}
@@ -45,6 +45,12 @@
</td> </td>
</ng-container> </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>
</ng-container>
<!-- Reason Column --> <!-- Reason Column -->
<ng-container matColumnDef="reason"> <ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th> <th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th>
@@ -114,6 +114,15 @@
max-width: 400px; max-width: 400px;
} }
.income-limit-cell {
color: #334155;
font-size: 13px;
font-weight: 500;
white-space: normal;
max-width: 200px;
line-height: 1.35;
}
/* Badges for Risk Category */ /* Badges for Risk Category */
.risk-badge { .risk-badge {
display: inline-flex; display: inline-flex;
@@ -42,6 +42,7 @@ export class AmlRulesComponent implements OnInit {
'orderPosition', 'orderPosition',
'ruleName', 'ruleName',
'riskCategory', 'riskCategory',
'incomeLimit',
'reason', 'reason',
'amlCheckStatus', 'amlCheckStatus',
'bannedEntityMatch' 'bannedEntityMatch'
@@ -84,4 +85,39 @@ export class AmlRulesComponent implements OnInit {
return ''; 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';
}
} }
+134
View File
@@ -0,0 +1,134 @@
/**
* 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/.
*/
/**
* Shapes common to every RBI CIMS off-site surveillance return (OSS 1..7).
* Kept free of any ExcelJS import so data files stay dependency-light.
*/
/** One labelled row of a return. */
export interface ReturnLine {
label: string;
/** 0 = numbered head, 1 = roman sub-item, 2 = a/b/c leaf. Drives indentation. */
level: number;
/** null renders as an empty cell, never as 0 — RBI treats blank and zero differently. */
value: number | null;
/** Derived/aggregate row — shaded and bold, like the blue cells in the workbook. */
total?: boolean;
/** Row is a headcount (depositors / borrowers / employees), not a rupee amount. */
count?: boolean;
}
/**
* One row of a multi-column statement. Returns from OSS 3 onwards are matrices
* (risk category x asset class) rather than the single-value lists of OSS 1/2.
*/
export interface MatrixRow {
label: string;
/** 0 = numbered head, 1..n = progressively indented sub-items. */
level: number;
/** One entry per data column; null renders blank, never 0. */
values: (number | null)[];
/** Derived/aggregate row — shaded and bold. */
total?: boolean;
/** Caption-only row that spans the table with no figures of its own. */
header?: boolean;
/** Render values as percentages rather than amounts. */
percent?: boolean;
}
/** A labelled statement made of a header band plus matrix rows. */
export interface MatrixTable {
title: string;
subtitle?: string;
/** Column captions excluding the leading label column. */
columns: string[];
rows: MatrixRow[];
notes?: string[];
unit?: string;
}
/** A signatory block as it appears on the Signatory sheet of every OSS return. */
export interface ReturnSignatory {
heading: string;
signature: string;
name: string;
designation: string;
email: string;
telephoneOffice: string;
telephoneResidence: string;
place: string;
date: string;
}
/** A `label -> value` row on the General Information sheet. */
export interface ReturnGeneralInfoRow {
label: string;
value: string;
}
/** Field order shared by the Signatory sheet of every OSS return. */
export const SIGNATORY_FIELDS: { key: keyof ReturnSignatory; caption: string }[] = [
{ key: 'signature', caption: 'Signature' },
{ key: 'name', caption: 'Name' },
{ key: 'designation', caption: 'Designation' },
{ key: 'email', caption: 'e-mail ID' },
{ key: 'telephoneOffice', caption: 'Tele. No. (O)' },
{ key: 'telephoneResidence', caption: 'Tele. No. (R)' },
{ key: 'place', caption: 'Place' },
{ key: 'date', caption: 'Date' }
];
/**
* Identity of the reporting bank, shared by every OSS return so the General
* Information sheets can never drift apart between OSS 1 and OSS 2.
*/
export const OSS_BANK = {
name: 'CBSNEXORA BANK LTD., HEAD OFFICE',
bankCode: '91007',
roName: 'MUMBAI',
institutionType: 'Urban Cooperative Banks',
reportingFrequency: 'Quarterly',
quarterEndDate: '30-Jun-2026',
auditFlag: 'Un-Audited',
dateOfAudit: '',
returnVersion: 'V1.0',
startDate: '01-Apr-2026',
/** OSS 3 prints dates as DD-MM-YYYY rather than DD-Mon-YYYY. */
startDateNumeric: '01-04-2026',
quarterEndDateNumeric: '30-06-2026'
};
/**
* Signatories for the demo bank. The `.example` domain is reserved for
* documentation, so these addresses can never reach a real inbox.
*/
export const OSS_SIGNATORIES: ReturnSignatory[] = [
{
heading: 'Authorised Reporting Official',
signature: 'ROHAN DESHPANDE',
name: 'ROHAN DESHPANDE',
designation: 'MANAGER — REGULATORY REPORTING',
email: 'rohan.deshpande@cbsnexorabank.example',
telephoneOffice: '022-40015520',
telephoneResidence: '022-40015588',
place: 'MUMBAI',
date: '14/07/2026'
},
{
heading: 'Countersigned By',
signature: 'ANJALI MENON',
name: 'ANJALI MENON',
designation: 'CHIEF EXECUTIVE OFFICER',
email: 'ceo@cbsnexorabank.example',
telephoneOffice: '022-40015500',
telephoneResidence: '022-40015501',
place: 'MUMBAI',
date: '14/07/2026'
}
];
+417
View File
@@ -0,0 +1,417 @@
/**
* 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 {
/* Mirrors the RBI CIMS workbook palette so the page and the export read alike. */
--o-navy: #002f5f;
--o-blue: #1d4ed8;
--o-blue-l: #eff6ff;
--o-derived: #cfe2f8;
--o-green: #15803d;
--o-green-l: #dcfce7;
--o-amber-l: #ffc;
--o-amber-d: #92400e;
--o-red: #c0392b;
--o-red-l: #fdecea;
--o-g50: #f8fafc;
--o-g100: #f1f5f9;
--o-g200: #e2e8f0;
--o-g300: #cbd5e1;
--o-g500: #64748b;
--o-g700: #334155;
--o-g900: #0f172a;
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
background: var(--o-g50);
color: var(--o-g900);
font-size: 14px;
line-height: 1.5;
}
.oss-root {
padding: 18px 22px;
max-width: 1500px;
margin: 0 auto;
}
/* HEADER */
.oss-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.oss-head-left {
display: flex;
align-items: center;
gap: 14px;
}
.oss-head-ic {
font-size: 28px;
color: var(--o-navy);
}
.oss-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.oss-head-s {
font-size: 12px;
color: var(--o-g500);
}
.oss-head-right {
display: flex;
align-items: center;
gap: 12px;
}
.oss-audit-pill {
padding: 4px 12px;
border-radius: 999px;
background: var(--o-amber-l);
color: var(--o-amber-d);
border: 1px solid #fcd34d;
font-size: 12px;
font-weight: 700;
}
/* BUTTONS */
.oss-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border: 1px solid var(--o-g300);
background: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--o-g700);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.oss-btn-xls {
background: #16a34a;
border-color: #16a34a;
color: #fff;
&:hover:not(:disabled) {
background: #15803d;
border-color: #15803d;
}
}
/* BALANCE CHECK */
.oss-balance {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: 10px;
background: var(--o-green-l);
border: 1px solid #86efac;
color: #14532d;
font-size: 13px;
margin-bottom: 14px;
&--bad {
background: var(--o-red-l);
border-color: #fca5a5;
color: #7f1d1d;
}
}
/* TABS */
.oss-tabs {
display: flex;
gap: 4px;
background: #fff;
border: 1px solid var(--o-g200);
border-radius: 10px;
padding: 4px;
margin-bottom: 16px;
overflow-x: auto;
}
.oss-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border: none;
background: transparent;
font-size: 13px;
font-weight: 500;
color: var(--o-g500);
cursor: pointer;
border-radius: 7px;
white-space: nowrap;
transition: all 0.15s;
font-family: inherit;
&:hover {
color: var(--o-g900);
}
&.active {
background: var(--o-navy);
color: #fff;
box-shadow: 0 2px 6px rgb(0 47 95 / 25%);
}
}
.oss-section {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Full-width banner, matching the dark blue header band in the workbook. */
.oss-sheet-banner {
background: var(--o-navy);
color: #fff;
padding: 10px 18px;
border-radius: 8px;
font-size: 14px;
font-weight: 700;
text-align: center;
letter-spacing: 0.02em;
}
/* TABLES */
.oss-tbl-wrap {
background: #fff;
border: 1px solid var(--o-g200);
border-radius: 10px;
overflow: auto;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
}
.oss-tbl-h {
background: var(--o-blue-l);
color: var(--o-navy);
padding: 10px 16px;
font-size: 12px;
font-weight: 700;
border-bottom: 1px solid var(--o-g200);
}
.oss-tbl {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
padding: 9px 12px;
font-size: 11px;
font-weight: 700;
color: var(--o-g700);
background: var(--o-g100);
border-bottom: 1px solid var(--o-g200);
border-right: 1px solid var(--o-g200);
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--o-g100);
border-right: 1px solid var(--o-g100);
font-size: 13px;
color: var(--o-g700);
}
tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: var(--o-g50);
}
}
/* Key/value layout used by General Information and Signatory. */
.oss-tbl--kv {
th {
width: 40%;
background: #d9d9d9;
color: var(--o-g900);
}
td.filled {
background: var(--o-derived);
}
td.audit {
background: var(--o-amber-l);
font-weight: 700;
}
}
/* Derived/aggregate rows — the blue cells in the original sheet. */
.total-row td {
font-weight: 700;
color: var(--o-g900);
}
td.derived {
background: var(--o-derived);
font-weight: 700;
color: var(--o-navy);
}
/* Indentation matching the return's numbered / roman / lettered hierarchy. */
.lvl-0 {
font-weight: 600;
}
.lvl-1 {
padding-left: 30px !important;
}
.lvl-2 {
padding-left: 52px !important;
}
.lvl-3 {
padding-left: 74px !important;
}
/* Caption-only row spanning a statement (e.g. "PRIORITY SECTOR"). */
.section-row td {
background: var(--o-g100);
font-weight: 700;
color: var(--o-g900);
}
/* Wide matrices keep their label column readable while the figures scroll. */
.oss-col-label {
min-width: 320px;
}
.oss-tbl-unit {
float: right;
font-weight: 600;
color: var(--o-g500);
}
.oss-tbl-sub {
padding: 8px 16px;
font-size: 11px;
font-weight: 600;
color: var(--o-g700);
background: var(--o-g100);
border-bottom: 1px solid var(--o-g200);
}
.ta-c {
text-align: center;
}
.ta-r {
text-align: right;
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 12px;
}
/* NOTES */
.oss-notes {
background: var(--o-g100);
border: 1px solid var(--o-g200);
border-radius: 8px;
padding: 10px 16px;
}
.oss-note-line {
font-size: 12px;
font-weight: 600;
color: var(--o-g700);
padding: 2px 0;
}
.oss-foot-note {
margin: -6px 2px 0;
font-size: 11px;
color: var(--o-g500);
}
/* Tie-up status pills (cross-return reconciliation checks). */
.report-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
&.report-ok {
background: var(--o-green-l);
color: var(--o-green);
}
&.report-bad {
background: var(--o-red-l);
color: var(--o-red);
}
}
.oss-sign-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* TOAST */
.oss-toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--o-navy);
color: #fff;
padding: 12px 22px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
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 <= 1000px) {
.oss-sign-grid {
grid-template-columns: 1fr;
}
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import * as ExcelJS from 'exceljs';
import {
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 = {
banner: 'FF002F5F',
bannerText: 'FFFFFFFF',
headerGrey: 'FFC0C0C0',
derivedBlue: 'FF9CC3F5',
auditYellow: 'FFFFFFCC',
keyCell: 'FFD9D9D9',
border: 'FF000000'
};
const AMOUNT_FMT = '#,##0.00';
const COUNT_FMT = '#,##0';
export function solidFill(argb: string): ExcelJS.Fill {
return { type: 'pattern', pattern: 'solid', fgColor: { argb } };
}
/** Dark-blue full-width banner that heads every sheet in the RBI templates. */
export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: string): void {
const row = sheet.addRow([title]);
sheet.mergeCells(`A${row.number}:${lastCol}${row.number}`);
row.height = 26;
const cell = row.getCell(1);
cell.fill = solidFill(XL.banner);
cell.font = { bold: true, size: 13, color: { argb: XL.bannerText } };
cell.alignment = { vertical: 'middle', horizontal: 'center' };
sheet.addRow([]);
}
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 = {
top: { style: 'thin', color: { argb: XL.border } },
left: { style: 'thin', color: { argb: XL.border } },
bottom: { style: 'thin', color: { argb: XL.border } },
right: { style: 'thin', color: { argb: XL.border } }
};
}
}
}
export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void {
const row = sheet.addRow([label, unitLabel ?? '']);
row.font = { bold: true };
row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey)));
row.getCell(2).alignment = { horizontal: 'right' };
}
/** 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 labelCell = row.getCell(1);
const valueCell = row.getCell(2);
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: line.level };
if (line.level === 0) {
labelCell.font = { bold: true };
}
valueCell.numFmt = line.count ? COUNT_FMT : AMOUNT_FMT;
valueCell.alignment = { horizontal: 'right' };
if (line.total) {
valueCell.fill = solidFill(XL.derivedBlue);
valueCell.font = { bold: true };
}
}
}
/**
* Writes a multi-column statement: title band, column header row, then the matrix
* rows. Returns the row number after the block so callers can keep stacking.
*/
export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): void {
const colCount = table.columns.length + 1;
const start = sheet.rowCount + 1;
const titleRow = sheet.addRow([table.title, ...(table.unit ? [table.unit] : [])]);
if (!table.unit) {
sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount);
}
titleRow.font = { bold: true };
for (let c = 1; c <= colCount; c++) {
titleRow.getCell(c).fill = solidFill(XL.headerGrey);
}
if (table.subtitle) {
const sub = sheet.addRow([table.subtitle]);
sheet.mergeCells(sub.number, 1, sub.number, colCount);
sub.font = { bold: true, size: 10 };
sub.alignment = { wrapText: true };
sub.getCell(1).fill = solidFill(XL.headerGrey);
}
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 labelCell = excelRow.getCell(1);
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level };
if (row.level === 0 || row.header) {
labelCell.font = { bold: true };
}
if (row.header) {
labelCell.fill = solidFill(XL.headerGrey);
continue;
}
for (let c = 2; c <= colCount; c++) {
const cell = excelRow.getCell(c);
cell.numFmt = row.percent ? '0.00"%"' : AMOUNT_FMT;
cell.alignment = { horizontal: 'right' };
if (row.total) {
cell.fill = solidFill(XL.derivedBlue);
cell.font = { bold: true };
}
}
}
boxAllCells(sheet, start, sheet.rowCount, colCount);
for (const note of table.notes ?? []) {
const noteRow = sheet.addRow([note]);
sheet.mergeCells(noteRow.number, 1, noteRow.number, colCount);
noteRow.font = { bold: true, size: 9 };
noteRow.alignment = { wrapText: true };
noteRow.getCell(1).fill = solidFill(XL.headerGrey);
}
sheet.addRow([]);
}
/** The General Information sheet is identical in shape across every OSS return. */
export function buildGeneralInfoSheet(wb: ExcelJS.Workbook, rows: ReturnGeneralInfoRow[]): void {
const sheet = wb.addWorksheet('General Information');
sheet.columns = [
{ width: 26 },
{ 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]);
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;
row.getCell(2).fill = solidFill(fill);
}
boxAllCells(sheet, first, sheet.rowCount, 2);
}
/** The Signatory sheet is likewise identical across returns. */
export function buildSignatorySheet(
wb: ExcelJS.Workbook,
signatories: ReturnSignatory[],
sheetName = 'Signatory'
): void {
const sheet = wb.addWorksheet(sheetName);
sheet.columns = [
{ width: 44 },
{ width: 24 }
];
addBanner(sheet, sheetName, 'B');
for (const person of signatories) {
const start = sheet.rowCount + 1;
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]);
row.getCell(2).font = { bold: true };
row.getCell(2).alignment = { horizontal: 'center' };
row.getCell(2).fill = solidFill(XL.keyCell);
}
boxAllCells(sheet, start, sheet.rowCount, 2);
sheet.addRow([]);
}
}
/** Serialises the workbook and triggers a browser download. */
export async function downloadWorkbook(wb: ExcelJS.Workbook, filename: string): Promise<void> {
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 = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function newWorkbook(): ExcelJS.Workbook {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBSNexora';
wb.created = new Date();
return wb;
}
@@ -0,0 +1,431 @@
/**
* 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/.
*/
/**
* OSS 1 — Return on Assets and Liabilities (RAL), return code R070.
*
* Mirrors the RBI CIMS "Form RAL_CIMS Template1 OSS 1" workbook sheet-for-sheet:
* General Information / Assets & Liabilities / Annexure 1 / Annexure 2 / Signatory
*
* All monetary values are Rs in Thousands, exactly as the return prescribes.
* A `null` value means the source return leaves that cell blank — blank and zero
* are different to RBI, so they must not be collapsed into one another.
*/
import { OSS_BANK, OSS_SIGNATORIES, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory } from '../oss-return.model';
/** One labelled row of the return. */
export type RalLine = ReturnLine;
export interface RalTopDeposit {
srNo: number;
institution: string;
amount: number | null;
rateOfInterest: number | null;
dateOfReceipt: string;
days: number | null;
months: number | null;
years: number | null;
}
export interface RalAgeingRow {
period: string;
noOfDebitEntries: number | null;
amtOfDebitEntries: number | null;
noOfCreditEntries: number | null;
amtOfCreditEntries: number | null;
}
export interface RalUnsecuredGuarantee {
srNo: number;
borrower: string;
beneficiary: string;
amountOutstanding: number | null;
}
export interface RalSundryDebtor {
srNo: number;
dueFromName: string;
amountDue: number | null;
provisionMade: number | null;
}
export type RalSignatory = ReturnSignatory;
/**
* Sheet 1 — General Information.
* Return Name / Return Code are fixed by RBI; everything else is this bank's own
* identity and the quarter being reported.
*/
export const RAL_GENERAL_INFO: ReturnGeneralInfoRow[] = [
{ label: 'Return Name', value: 'Return on Assets and Liabilities (RAL)' },
{ label: 'Return Code', value: 'R070' },
{ label: 'Bank Name', value: OSS_BANK.name },
{ label: 'Bank Code', value: OSS_BANK.bankCode },
{ label: 'RO Name', value: OSS_BANK.roName },
{ label: 'Reporting Frequency', value: OSS_BANK.reportingFrequency },
{ label: 'Quarter End Date', value: OSS_BANK.quarterEndDate },
{ label: 'Audit Flag', value: OSS_BANK.auditFlag },
{ label: 'Date of Audit', value: OSS_BANK.dateOfAudit },
{ label: 'Return Version', value: OSS_BANK.returnVersion },
{ label: 'Start Date', value: OSS_BANK.startDate }
];
/** Sheet 2 — Assets & Liabilities :: Liabilities */
export const RAL_LIABILITIES: RalLine[] = [
{ label: '1. Paid-up Capital', level: 0, value: 27278, total: true },
{ label: 'i. Individuals', level: 1, value: 27278 },
{ label: 'ii. State Government', level: 1, value: null },
{ label: 'iii. Others', level: 1, value: null },
{ label: '2. Reserve Fund and other Reserves', level: 0, value: 60863, total: true },
{ label: 'i. Statutory Reserve Fund', level: 1, value: 31149 },
{ label: 'ii. Building Fund', level: 1, value: 13642 },
{ label: 'iii. Dividend Equalization Fund', level: 1, value: 36 },
{ label: 'iv. Revaluation Reserve', level: 1, value: null },
{ label: 'v. Provision for Standard Assets', level: 1, value: 3750 },
{ label: 'vi. General Provisions (other than provision for standard advances)', level: 1, value: 282 },
{
label: 'vii. Other Funds and Reserves not in the nature of outside liability-(Refer Annexure I)',
level: 1,
value: 12004
},
{ label: '3. Profit (+) / Loss (-)', level: 0, value: 10397, total: true },
{ label: 'i. Surplus Unallocated and Carried over from Previous years', level: 1, value: 5592 },
{ label: 'ii. Accumulated Losses of Previous years', level: 1, value: null },
{ label: 'iii. Operating Surplus in Current year', level: 1, value: 4805 },
{ label: 'iv. Operating Deficit in Current year', level: 1, value: null },
{ label: '4. Total Capital And Reserves', level: 0, value: 98538, total: true },
{ label: '5. Subordinated Debt (Tier II Capital) #', level: 0, value: null },
{ label: '6. Deposits and other Accounts', level: 0, value: 634309, total: true },
{ label: 'Customer Deposits -', level: 1, value: 634309, total: true },
{ label: 'Total No of Depositors', level: 1, value: 8156, total: true, count: true },
{ label: 'A. Resident Deposits', level: 1, value: 634309, total: true },
{
label:
'i. Term Deposits (including recurring deposits, cash certificates, staff provident funds,security deposits and matured term deposits)',
level: 2,
value: 406601
},
{ label: 'No. of Term Depositors', level: 2, value: 3462, count: true },
{ label: 'ii. Savings Bank Deposits', level: 2, value: 208116 },
{ label: 'No of Savings Depositors', level: 2, value: 4455, count: true },
{ label: 'iii. Current Deposits', level: 2, value: 19592 },
{ label: 'No of Current Depositors', level: 2, value: 239, count: true },
{ label: 'B. Non Resident Deposits', level: 1, value: 0, total: true },
{ label: '1. NRI Deposits (Other than deposits in NRO Accounts)', level: 2, value: 0, total: true },
{ label: 'i. FCNR Deposits', level: 2, value: null },
{ label: 'No of FCNR Depositors', level: 2, value: null, count: true },
{ label: 'ii. NRE Deposits', level: 2, value: null },
{ label: 'No of NRE Depositors', level: 2, value: null, count: true },
{ label: '2. NRO Deposits', level: 2, value: null, total: true },
{ label: 'No of NRO Depositors', level: 2, value: null, count: true },
{ label: 'C. Deposit of Banks', level: 1, value: 0, total: true },
{ label: 'i. Commercial Banks', level: 2, value: null },
{ label: 'ii. Scheduled Cooperative Banks', level: 2, value: null },
{ label: 'iii. Other Cooperative Banks', level: 2, value: null },
{ label: '7. Borrowings from', level: 0, value: 0, total: true },
{ label: 'i. RBI / NABARD / SIDBI / NHB', level: 1, value: null },
{ label: 'ii. State/Central Coop. Bank of the State/ District', level: 1, value: 0, total: true },
{ label: 'a. Short-term loans', level: 2, value: null },
{ label: 'b. Medium-term loans', level: 2, value: null },
{ label: 'c. Cash credits and overdrafts', level: 2, value: null },
{ label: 'iii. Other banks (exclude borrowings from call money market)', level: 1, value: null },
{ label: 'iv. Call money market', level: 1, value: null },
{ label: 'v. Long Term Deposits', level: 1, value: null },
{ label: 'vi. Others', level: 1, value: null },
{ label: '8. Bills for collection being bills receivable as per contra', level: 0, value: null },
{ label: '9. Branch Adjustments *', level: 0, value: null },
{ label: '10. Overdue Interest Reserve', level: 0, value: 32096 },
{ label: '11. Interest Payable on', level: 0, value: 731, total: true },
{ label: 'i. Deposits', level: 1, value: 731 },
{ label: 'ii. Borrowings', level: 1, value: null },
{
label:
'12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
level: 0,
value: 39440,
total: true
},
{ label: 'i. For investments (Investment Depreciation Reserve)', level: 1, value: 3750 },
{ label: 'ii. BDDR, Spl BDDR', level: 1, value: 24341 },
{ label: 'iii. Depreciation on fixed assets', level: 1, value: null },
{ label: 'iv. For other impaired assets', level: 1, value: 11349 },
{ label: 'v. For contingent / non-funded exposures', level: 1, value: null },
{ label: '13. Other Liabilities', level: 0, value: 5806, total: true },
{ label: 'i. Bills payable', level: 1, value: 291 },
{ label: 'ii. Unclaimed dividends', level: 1, value: null },
{ label: 'iii.Provident fund payable to Regional Provident Fund Commissioner', level: 1, value: null },
{ label: 'iv.Others', level: 1, value: 5515 },
{ label: '14. Total Outside Liabilities', level: 0, value: 712382, total: true },
{ label: '15. Total Liabilities', level: 0, value: 810920, total: true },
{ label: '16. Contingent Liabilities', level: 0, value: 2638, total: true },
{ label: 'i. Claims against the bank not acknowledged as debt', level: 1, value: null },
{ label: 'ii. Liabilities for partly paid investments', level: 1, value: null },
{ label: 'iii. Letter of Credit Documentary', level: 1, value: null },
{ label: 'iv. Guarantees - Financial', level: 1, value: null },
{ label: 'v. Guarantees- Others (Indemnity/ Tender/ Bid Bonds)', level: 1, value: null },
{ label: 'vi. Sale and Repurchase Agreement /Asset sales with recourse', level: 1, value: null },
{ label: 'vii. Liability on account of outstanding forward exchange contracts', level: 1, value: null },
{ label: 'viii. Acceptances, endorsements and other obligations', level: 1, value: null },
{ label: 'ix. Others', level: 1, value: 2638 }
];
/** Sheet 2 — Assets & Liabilities :: Assets */
export const RAL_ASSETS: RalLine[] = [
{ label: '1. Cash in Hand', level: 0, value: 9996 },
{ 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: 'a. Current', level: 2, value: 5980 },
{ label: 'b. Savings', level: 2, value: null },
{ label: 'c. Fixed', level: 2, value: 26288 },
{ label: 'ii. RBI :', level: 2, value: 0, total: true },
{ label: 'a. Current', level: 2, value: null },
{ label: 'b. Others', level: 2, value: null },
{ label: 'iii. SBI and other notified banks :', level: 2, value: 25332, total: true },
{ label: 'a. Current', level: 2, value: 13667 },
{ label: 'b. Savings', level: 2, value: null },
{ label: 'c. Fixed', level: 2, value: 11665 },
{ label: 'B. Loans and Advances to Banks (including bills)', level: 1, value: 0, total: true },
{ label: 'i. Call and Short Notice Money to Cooperative Banks', level: 2, value: null },
{ label: 'ii. Call and Short Notice Money to Other Banks', level: 2, value: null },
{ label: 'iii. Loans and Advances to Scheduled Cooperative Banks', level: 2, value: null },
{ label: 'iv.Loans and advances to Non scheduled Cooperative Banks', level: 2, value: null },
{ label: 'v. Loans and Advances to Other Banks', level: 2, value: null },
{
label: '2. II. Due from Other institutions (including post office / treasuries / others)',
level: 0,
value: 77353,
total: true
},
{ label: 'a. Current', level: 1, value: 9954 },
{ label: 'b. Fixed', level: 1, value: 67399 },
{ label: 'c. Others', level: 1, value: null },
{ label: '3. Investments', level: 0, value: 234297, total: true },
{
label: 'A. SLR Investments in Government and Other Approved Central and State Govt Securities',
level: 1,
value: 211734,
total: true
},
{ label: 'i. GOI T. Bills', level: 2, value: null },
{ label: 'ii. GOI (dated) Securities', level: 2, value: 63839 },
{ label: 'iii. State Government Securities', level: 2, value: 147895 },
{
label: 'iv. Other Trustee/approved securities including Land Development Bank debentures',
level: 2,
value: null
},
{ label: 'B. Non - SLR Investments', level: 1, value: 22563, total: true },
{ label: 'B1. Other Debt Securities', level: 2, value: 0, total: true },
{ label: 'i. Other State Government Securities', level: 2, value: null },
{ label: 'ii. Bonds of PSUs / All India Financial Institutions', level: 2, value: null },
{ label: 'B2. Equities / Units of Mutual Funds', level: 2, value: 63, total: true },
{ label: 'i. Shares in Cooperative Institutions', level: 2, value: 63 },
{ label: 'ii. Shares of All India Financial Institutions', level: 2, value: null },
{ label: 'iii. Units of Mutual Funds (UTI,etc)', level: 2, value: null },
{ label: 'B3. Investment in Subsidiaries / Equity Participations', level: 2, value: null },
{ label: 'B4. Other Investments', level: 2, value: 22500 },
{ label: '4. Loans and Advances', level: 0, value: 391288, total: true },
{ label: 'A. Gross Loans and Advances: (Other than banks)', level: 1, value: 391288, total: true },
{ label: 'Total No of Borrowers', level: 1, value: 363, total: true, count: true },
{ label: 'i. Short term loans', level: 2, value: 125082, total: true },
{ label: 'No of Borrowers', level: 2, value: 137, count: true },
{ label: 'a. Secured', level: 2, value: 125082 },
{ label: 'b. Unsecured', level: 2, value: null },
{ label: 'ii. Cash credits and overdrafts', level: 2, value: 166614, total: true },
{ label: 'No of Borrowers', level: 2, value: 140, count: true },
{ label: 'a. Secured', level: 2, value: 166614 },
{ label: 'b. Unsecured', level: 2, value: null },
{ label: 'iii. Medium - term loans', level: 2, value: 70236, total: true },
{ label: 'No of Borrowers', level: 2, value: 66, count: true },
{ label: 'a. Secured', level: 2, value: 70070 },
{ label: 'b. Unsecured', level: 2, value: 166 },
{ label: 'iv. Long - term loans', level: 2, value: 29356, total: true },
{ label: 'No of Borrowers', level: 2, value: 20, count: true },
{ label: 'a. Secured', level: 2, value: 29356 },
{ label: 'b. Unsecured', level: 2, value: null },
{ label: 'v. Bills/hundies purchased/discounted', level: 2, value: 0, total: true },
{ label: 'No of Borrowers', level: 2, value: null, count: true },
{ label: 'a. Documentary', level: 2, value: null },
{ label: 'b. Clean', level: 2, value: null },
{ label: 'B. Netting items on Loans and Advances', level: 1, value: 0, total: true },
{ label: 'i. Unrealised Interest in Suspense', level: 2, value: null },
{ label: 'ii. Credit Recoveries in Suspense', level: 2, value: null },
{ label: 'iii. Provisions for Credit Losses ( funded credits)', level: 2, value: null },
{ label: 'C. Loans and Advances (Net)', level: 1, value: 391288, total: true },
{ label: '5. Interest Receivable on', level: 0, value: 33541, total: true },
{ label: 'i. Investments', level: 1, value: 1445 },
{ label: 'ii.Loans and Advances', level: 1, value: 32096 },
{ label: 'Of 5 (ii) above, overdues', level: 1, value: 32096 },
{ label: '6. Bills Receivable being bills for collection as per contra', level: 0, value: null },
{ label: '7. Premises (less depreciation)', level: 0, value: 3185 },
{ label: '8. Furniture and Fixtures (less depreciation)', level: 0, value: 1763 },
{ label: '9. Branch Adjustments **', level: 0, value: null },
{ label: '10. Other Assets', level: 0, value: 1897, total: true },
{ label: 'i. Advance Tax Paid and TDS (net)', level: 1, value: null },
{ label: 'ii. Amounts due from RBI', level: 1, value: null },
{ label: 'iii. Sundry Debtors', level: 1, value: null },
{ label: 'iv. Intangible Assets', level: 1, value: null },
{ label: 'v. All Other', level: 1, value: 1897 },
{ label: '11. Non-banking Assets acquired in satisfaction of claims', level: 0, value: null },
{ label: '12. Total Assets (1 to 11)', level: 0, value: 810920, total: true }
];
/** Sheet 2 — Memo block */
export const RAL_MEMO: RalLine[] = [
{ label: '1.Capital Infusion during Apr to Date', level: 0, value: 0, total: true },
{ label: '1.1 Tier 1 Capital Raised during April to Date', level: 1, value: null },
{ label: '1.2 Tier 2 Capital Raised during April to Date', level: 1, value: null },
{ label: '2. Capital Instruments due for redemption in next twelve months', level: 0, value: null }
];
export const RAL_BALANCE_SHEET_NOTES: string[] = [
'Note : * To be furnished as per the books of the bank.',
'Note : ** Branch adjustments: Net position to be shown as an item of liability / assets, as the case may be.',
'Note: # Capital raised by way of RCPS / RNCPS / PNCPS to be reported under this item'
];
/** Sheet 3 — Annexure 1 :: 1. Break-up of Other Funds and Reserves */
export const RAL_OTHER_FUNDS: RalLine[] = [
{ label: 'i. Gratuity Fund', level: 1, value: null },
{ label: 'ii. Staff Welfare Fund', level: 1, value: 1310 },
{ label: 'iii. Silver Jubilee Fund', level: 1, value: null },
{ label: 'iv. Development Fund', level: 1, value: 36 },
{ label: 'v. Charity Fund', level: 1, value: 1718 },
{ label: 'vi. Any Other Fund/ Disclosed reserves', level: 1, value: 8940 },
{
label:
'vii. Undisclosed Reserves created out of PAT and included in B/S under heads like other liabilities and provisions',
level: 1,
value: null
},
{ label: 'Total', level: 0, value: 12004, total: true }
];
/** Sheet 3 — Annexure 1 :: 2. Top 20 deposits from other banks / institutions */
export const RAL_TOP_20_DEPOSITS: RalTopDeposit[] = Array.from(
{ length: 20 },
(_, i): RalTopDeposit => ({
srNo: i + 1,
institution: '',
amount: null,
rateOfInterest: null,
dateOfReceipt: '',
days: null,
months: null,
years: null
})
);
/** Column captions shared by the two age-wise analyses in Annexure 1. */
export const RAL_AGEING_HEADERS: string[] = [
'Time period',
'No. of debit entries',
'Amt. of debit entries',
'No. of credit entries',
'Amt. of credit entries'
];
/** 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: '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: '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 }
];
export const RAL_UNSECURED_GUARANTEES_TOTAL = 0;
/** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */
export const RAL_SUNDRY_DEBTORS: { title: string; subtitle: string; rows: RalSundryDebtor[] }[] = [
{
title: 'Sundry Debtors Account -1',
subtitle: 'Long Outstanding Large Amounts - Outstanding for over 6 months but less than 1 year*',
rows: [{ srNo: 1, dueFromName: '', amountDue: null, provisionMade: null }]
},
{
title: 'Sundry Debtors Account -2',
subtitle: 'Long Outstanding Large Amounts - Outstanding for over 1 year but less than 3 years*',
rows: [{ srNo: 1, dueFromName: '', amountDue: null, provisionMade: null }]
},
{
title: 'Sundry Debtors Account -3',
subtitle: 'Long Outstanding Large Amounts - Outstanding for over 3 years*',
rows: [{ srNo: 1, dueFromName: '', amountDue: null, provisionMade: null }]
}
];
export const RAL_SUNDRY_DEBTORS_NOTE =
'* Large Amounts: Rs. 2 lakhs for banks having total assets of Rs. 100 lakhs and over Rs. 50,000 for other banks';
/** Sheet 4 — Annexure 2 :: Details of Depositors & Borrowers */
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: '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: '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: '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: '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: '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: 'ii) Of which total Loans and Advances Amount', level: 1, value: 21260 }
];
/** Sheet 4 — Annexure 2 :: Complaints Received from public */
export const RAL_COMPLAINTS: RalLine[] = [
{ label: 'a) No. Of complaints outstanding at the beginning of the quarter', level: 1, value: 0, count: true },
{ label: 'b) No. Of complaints received during the quarter', level: 1, value: 0, count: true },
{ label: 'c) No. of complaints addressed during the quarter', level: 1, value: 0, count: true },
{ label: 'd) No. of complaints outstanding at the end of the quarter', level: 1, value: 0, count: true },
{ label: 'e) No. of complaints outstanding for over 2 quarters', level: 1, value: 0, count: true }
];
/** Sheet 5 — Signatory. Shared across returns so OSS 1 and OSS 2 always agree. */
export const RAL_SIGNATORIES: RalSignatory[] = OSS_SIGNATORIES;
@@ -0,0 +1,422 @@
<!--
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="oss-root">
<!-- HEADER -->
<div class="oss-head">
<div class="oss-head-left">
<fa-icon icon="university" class="oss-head-ic"></fa-icon>
<div>
<h2 class="oss-head-t">OSS 1 — {{ returnName }}</h2>
<div class="oss-head-s">
{{ bankName }} &middot; Bank Code <strong>{{ bankCode }}</strong> &middot; Return Code
<strong>{{ returnCode }}</strong> &middot; Quarter ending <strong>{{ quarterEndDate }}</strong>
</div>
</div>
</div>
<div class="oss-head-right">
<span class="oss-audit-pill">{{ auditFlag }}</span>
<button class="oss-btn oss-btn-xls" [disabled]="generating" (click)="generateExcel()">
<fa-icon icon="file-csv"></fa-icon>
{{ generating ? 'Generating…' : 'Generate Excel' }}
</button>
</div>
</div>
<!-- BALANCE CHECK -->
<div class="oss-balance" [class.oss-balance--bad]="!isBalanced">
<fa-icon [icon]="isBalanced ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span>
Total Liabilities <strong>&#8377;{{ totalLiabilities | number: '1.2-2' }}</strong> &middot; Total Assets
<strong>&#8377;{{ totalAssets | number: '1.2-2' }}</strong> (Rs in Thousands) —
{{ isBalanced ? 'balance sheet tallies' : 'MISMATCH — resolve before filing with RBI' }}
</span>
</div>
<!-- TABS -->
<div class="oss-tabs">
<button class="oss-tab" [class.active]="activeTab === 'general'" (click)="showTab('general')">
<fa-icon icon="info-circle"></fa-icon> General Information
</button>
<button class="oss-tab" [class.active]="activeTab === 'balance-sheet'" (click)="showTab('balance-sheet')">
<fa-icon icon="university"></fa-icon> Assets &amp; Liabilities
</button>
<button class="oss-tab" [class.active]="activeTab === 'annexure1'" (click)="showTab('annexure1')">
<fa-icon icon="file-alt"></fa-icon> Annexure 1
</button>
<button class="oss-tab" [class.active]="activeTab === 'annexure2'" (click)="showTab('annexure2')">
<fa-icon icon="file-alt"></fa-icon> Annexure 2
</button>
<button class="oss-tab" [class.active]="activeTab === 'signatory'" (click)="showTab('signatory')">
<fa-icon icon="check-circle"></fa-icon> Signatory
</button>
</div>
<!-- GENERAL INFORMATION -->
@if (activeTab === 'general') {
<div class="oss-section">
<div class="oss-sheet-banner">General Information</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl oss-tbl--kv">
<tbody>
@for (item of generalInfo; track item.label) {
<tr>
<th>{{ item.label }}</th>
<td [class.filled]="item.value" [class.audit]="item.label === 'Audit Flag'">{{ item.value }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- ASSETS & LIABILITIES -->
@if (activeTab === 'balance-sheet') {
<div class="oss-section">
<div class="oss-sheet-banner">Assets &amp; Liabilities</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl">
<thead>
<tr>
<th>Liabilities</th>
<th class="ta-r">Rs in Thousands</th>
</tr>
</thead>
<tbody>
@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') }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl">
<thead>
<tr>
<th>Assets</th>
<th class="ta-r">Rs in Thousands</th>
</tr>
</thead>
<tbody>
@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') }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="oss-notes">
@for (note of balanceSheetNotes; track note) {
<div class="oss-note-line">{{ note }}</div>
}
</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl">
<thead>
<tr>
<th>Memo</th>
<th class="ta-r">Rs in Thousands</th>
</tr>
</thead>
<tbody>
@for (line of memo; 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: '1.2-2') }}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- ANNEXURE 1 -->
@if (activeTab === 'annexure1') {
<div class="oss-section">
<div class="oss-sheet-banner">Annexure 1</div>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">1. Break-up of Other Funds and Reserves &mdash; Rs in Thousands</div>
<table class="oss-tbl">
<tbody>
@for (line of otherFunds; track line.label) {
<tr [class.total-row]="line.total">
<td>{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total">
{{ line.value === null ? '' : (line.value | number: '1.2-2') }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">
2. Particulars of top 20 deposits accepted from other banks / and institutions as on the date of review
</div>
<table class="oss-tbl">
<thead>
<tr>
<th class="ta-c">Sr. No</th>
<th>Name of Bank / Institution</th>
<th class="ta-r">Amount of deposit</th>
<th class="ta-r">Rate of interest (%)</th>
<th>Date of receipt</th>
<th class="ta-c">Days</th>
<th class="ta-c">Months</th>
<th class="ta-c">Years</th>
</tr>
</thead>
<tbody>
@for (d of topDeposits; track d.srNo) {
<tr>
<td class="ta-c">{{ d.srNo }}</td>
<td>{{ d.institution }}</td>
<td class="ta-r mono">{{ d.amount === null ? '' : (d.amount | number: '1.2-2') }}</td>
<td class="ta-r mono">{{ d.rateOfInterest }}</td>
<td>{{ d.dateOfReceipt }}</td>
<td class="ta-c mono">{{ d.days }}</td>
<td class="ta-c mono">{{ d.months }}</td>
<td class="ta-c mono">{{ d.years }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">
3. Age-wise analysis of Inter Branch Adjustment Account &mdash; Position as on: {{ quarterEndDate }}
</div>
<table class="oss-tbl">
<thead>
<tr>
@for (h of ageingHeaders; track h) {
<th [class.ta-r]="!$first">{{ h }}</th>
}
</tr>
</thead>
<tbody>
@for (r of interBranchAgeing; track r.period) {
<tr [class.total-row]="r.period === 'Total'">
<td>{{ r.period }}</td>
<td class="ta-r mono">{{ r.noOfDebitEntries }}</td>
<td class="ta-r mono">
{{ r.amtOfDebitEntries === null ? '' : (r.amtOfDebitEntries | number: '1.2-2') }}
</td>
<td class="ta-r mono">{{ r.noOfCreditEntries }}</td>
<td class="ta-r mono">
{{ r.amtOfCreditEntries === null ? '' : (r.amtOfCreditEntries | number: '1.2-2') }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">
4. Age-wise analysis of Inter Bank Reconciliation Account &mdash; Position as on: {{ quarterEndDate }}
</div>
<table class="oss-tbl">
<thead>
<tr>
@for (h of ageingHeaders; track h) {
<th [class.ta-r]="!$first">{{ h }}</th>
}
</tr>
</thead>
<tbody>
@for (r of interBankAgeing; track r.period) {
<tr [class.total-row]="r.period === 'Total'">
<td>{{ r.period }}</td>
<td class="ta-r mono">{{ r.noOfDebitEntries }}</td>
<td class="ta-r mono">
{{ r.amtOfDebitEntries === null ? '' : (r.amtOfDebitEntries | number: '1.2-2') }}
</td>
<td class="ta-r mono">{{ r.noOfCreditEntries }}</td>
<td class="ta-r mono">
{{ r.amtOfCreditEntries === null ? '' : (r.amtOfCreditEntries | number: '1.2-2') }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">5. Unsecured guarantees issued by the bank</div>
<table class="oss-tbl">
<thead>
<tr>
<th class="ta-c">Sr. No</th>
<th>Name of Borrower</th>
<th>Name of Beneficiary</th>
<th class="ta-r">Amount Outstanding as on: {{ quarterEndDate }}</th>
</tr>
</thead>
<tbody>
@for (g of unsecuredGuarantees; track g.srNo) {
<tr>
<td class="ta-c">{{ g.srNo }}</td>
<td>{{ g.borrower }}</td>
<td>{{ g.beneficiary }}</td>
<td class="ta-r mono">
{{ g.amountOutstanding === null ? '' : (g.amountOutstanding | number: '1.2-2') }}
</td>
</tr>
}
<tr class="total-row">
<td colspan="3">Total</td>
<td class="ta-r mono derived">{{ unsecuredGuaranteesTotal | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
<p class="oss-foot-note">
Note: * Give name if borrower/beneficiary is director/top management/related entity. Other guarantees may be
consolidated and reported against others.
</p>
</div>
}
<!-- ANNEXURE 2 -->
@if (activeTab === 'annexure2') {
<div class="oss-section">
<div class="oss-sheet-banner">Annexure 2</div>
@for (block of sundryDebtors; track block.title) {
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">{{ block.title }} &mdash; {{ block.subtitle }}</div>
<table class="oss-tbl">
<thead>
<tr>
<th class="ta-c">Sr. No</th>
<th>Due from Name</th>
<th class="ta-r">Amount due</th>
<th class="ta-r">Provision made</th>
</tr>
</thead>
<tbody>
@for (r of block.rows; track r.srNo) {
<tr>
<td class="ta-c">{{ r.srNo }}</td>
<td>{{ r.dueFromName }}</td>
<td class="ta-r mono">{{ r.amountDue === null ? '' : (r.amountDue | number: '1.2-2') }}</td>
<td class="ta-r mono">{{ r.provisionMade === null ? '' : (r.provisionMade | number: '1.2-2') }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<p class="oss-foot-note">{{ sundryDebtorsNote }}</p>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">Details of Depositors &amp; Borrowers &mdash; Rs in Thousands</div>
<table class="oss-tbl">
<tbody>
@for (line of depositorsBorrowers; 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') }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">Complaints Received from public</div>
<table class="oss-tbl">
<tbody>
@for (line of complaints; track line.label) {
<tr>
<td>{{ line.label }}</td>
<td class="ta-r mono">{{ line.value }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- SIGNATORY -->
@if (activeTab === 'signatory') {
<div class="oss-section">
<div class="oss-sheet-banner">Signatory</div>
<div class="oss-sign-grid">
@for (p of signatories; track p.heading) {
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">{{ p.heading }}</div>
<table class="oss-tbl oss-tbl--kv">
<tbody>
<tr>
<td>{{ p.signature }}</td>
<th>Signature</th>
</tr>
<tr>
<td>{{ p.name }}</td>
<th>Name</th>
</tr>
<tr>
<td>{{ p.designation }}</td>
<th>Designation</th>
</tr>
<tr>
<td>{{ p.email }}</td>
<th>e-mail ID</th>
</tr>
<tr>
<td class="mono">{{ p.telephoneOffice }}</td>
<th>Tele. No. (O)</th>
</tr>
<tr>
<td class="mono">{{ p.telephoneResidence }}</td>
<th>Tele. No. (R)</th>
</tr>
<tr>
<td>{{ p.place }}</td>
<th>Place</th>
</tr>
<tr>
<td>{{ p.date }}</td>
<th>Date</th>
</tr>
</tbody>
</table>
</div>
}
</div>
</div>
}
<!-- TOAST -->
<div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -0,0 +1,423 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import {
addBanner,
addLineRows,
addSectionHeader,
boxAllCells,
buildGeneralInfoSheet,
buildSignatorySheet,
downloadWorkbook,
newWorkbook,
solidFill,
XL
} from '../oss-workbook';
import {
RAL_AGEING_HEADERS,
RAL_ASSETS,
RAL_BALANCE_SHEET_NOTES,
RAL_COMPLAINTS,
RAL_DEPOSITORS_BORROWERS,
RAL_GENERAL_INFO,
RAL_INTER_BANK_AGEING,
RAL_INTER_BRANCH_AGEING,
RAL_LIABILITIES,
RAL_MEMO,
RAL_OTHER_FUNDS,
RAL_SIGNATORIES,
RAL_SUNDRY_DEBTORS,
RAL_SUNDRY_DEBTORS_NOTE,
RAL_TOP_20_DEPOSITS,
RAL_UNSECURED_GUARANTEES,
RAL_UNSECURED_GUARANTEES_TOTAL
} from './oss1-ral-data';
type Oss1Tab = 'general' | 'balance-sheet' | 'annexure1' | 'annexure2' | 'signatory';
@Component({
selector: 'mifosx-oss1-ral',
templateUrl: './oss1-ral.component.html',
styleUrls: ['../oss-return.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class Oss1RalComponent {
activeTab: Oss1Tab = 'general';
generalInfo = RAL_GENERAL_INFO;
liabilities = RAL_LIABILITIES;
assets = RAL_ASSETS;
memo = RAL_MEMO;
balanceSheetNotes = RAL_BALANCE_SHEET_NOTES;
otherFunds = RAL_OTHER_FUNDS;
topDeposits = RAL_TOP_20_DEPOSITS;
interBranchAgeing = RAL_INTER_BRANCH_AGEING;
interBankAgeing = RAL_INTER_BANK_AGEING;
ageingHeaders = RAL_AGEING_HEADERS;
unsecuredGuarantees = RAL_UNSECURED_GUARANTEES;
unsecuredGuaranteesTotal = RAL_UNSECURED_GUARANTEES_TOTAL;
sundryDebtors = RAL_SUNDRY_DEBTORS;
sundryDebtorsNote = RAL_SUNDRY_DEBTORS_NOTE;
depositorsBorrowers = RAL_DEPOSITORS_BORROWERS;
complaints = RAL_COMPLAINTS;
signatories = RAL_SIGNATORIES;
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: any;
showTab(tab: Oss1Tab): void {
this.activeTab = tab;
}
private info(label: string): string {
return this.generalInfo.find((g) => g.label === label)?.value ?? '';
}
get returnName(): string {
return this.info('Return Name');
}
get returnCode(): string {
return this.info('Return Code');
}
get bankName(): string {
return this.info('Bank Name');
}
get bankCode(): string {
return this.info('Bank Code');
}
get quarterEndDate(): string {
return this.info('Quarter End Date');
}
get auditFlag(): string {
return this.info('Audit Flag');
}
/** Total Liabilities must equal Total Assets — surfaced so a mismatch can't be filed unnoticed. */
get totalLiabilities(): number {
return this.liabilities.find((l) => l.label.startsWith('15.'))?.value ?? 0;
}
get totalAssets(): number {
return this.assets.find((a) => a.label.startsWith('12.'))?.value ?? 0;
}
get isBalanced(): boolean {
return this.totalLiabilities === this.totalAssets;
}
// ══════════════ Excel generation ══════════════
/** Rebuilds the RBI workbook: one sheet per section, original layout and styling. */
async generateExcel(): Promise<void> {
this.generating = true;
try {
const wb = newWorkbook();
buildGeneralInfoSheet(wb, this.generalInfo);
this.buildBalanceSheet(wb);
this.buildAnnexure1(wb);
this.buildAnnexure2(wb);
buildSignatorySheet(wb, this.signatories);
await downloadWorkbook(wb, `Form RAL_CIMS ${this.returnCode} OSS 1 - ${this.quarterEndDate}.xlsx`);
this.toast(`${this.returnCode} workbook generated for quarter ending ${this.quarterEndDate}`);
} catch {
this.toast('Could not generate the workbook — please retry');
} finally {
this.generating = false;
}
}
private buildBalanceSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Assets & Liabilities');
sheet.columns = [
{ width: 78 },
{ width: 20 }
];
addBanner(sheet, 'Assets & Liabilities', 'B');
const first = sheet.rowCount + 1;
addSectionHeader(sheet, 'Liabilities', 'Rs in Thousands');
addLineRows(sheet, this.liabilities);
addSectionHeader(sheet, 'Assets', 'Rs in Thousands');
addLineRows(sheet, this.assets);
boxAllCells(sheet, first, sheet.rowCount, 2);
sheet.addRow([]);
const notesStart = sheet.rowCount + 1;
for (const note of this.balanceSheetNotes) {
const row = sheet.addRow([note]);
sheet.mergeCells(`A${row.number}:B${row.number}`);
row.font = { bold: true, size: 10 };
row.getCell(1).fill = solidFill(XL.headerGrey);
}
boxAllCells(sheet, notesStart, sheet.rowCount, 2);
sheet.addRow([]);
const memoStart = sheet.rowCount + 1;
addSectionHeader(sheet, 'Memo', 'Rs in Thousands');
addLineRows(sheet, this.memo);
boxAllCells(sheet, memoStart, sheet.rowCount, 2);
sheet.views = [{ state: 'frozen', ySplit: 3 }];
}
private buildAnnexure1(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Annexure 1');
sheet.columns = [
{ width: 10 },
{ width: 46 },
{ width: 20 },
{ width: 18 },
{ width: 16 },
{ 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']);
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]);
sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(3).numFmt = '#,##0.00';
if (line.total) {
row.font = { bold: true };
row.getCell(3).fill = solidFill(XL.derivedBlue);
row.getCell(1).alignment = { horizontal: 'center' };
}
}
boxAllCells(sheet, start, sheet.rowCount, 3);
sheet.addRow([]);
// 2. Top 20 deposits from other banks / institutions
start = sheet.rowCount + 1;
const depTitle = sheet.addRow([
`2. Particulars of top 20 deposits accepted from other banks / and institutions as on the date of review`
]);
sheet.mergeCells(`A${depTitle.number}:H${depTitle.number}`);
depTitle.font = { bold: true };
depTitle.getCell(1).fill = solidFill(XL.headerGrey);
const depHead = sheet.addRow([
'Sr. No',
'Name of Bank / Institution',
'Amount of deposit',
'Rate of interest (%)',
'Date of receipt',
'Period in Days',
'Period in Months',
'Period in Years'
]);
depHead.font = { bold: true };
depHead.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
depHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const d of this.topDeposits) {
const row = sheet.addRow([
d.srNo,
d.institution,
d.amount,
d.rateOfInterest,
d.dateOfReceipt,
d.days,
d.months,
d.years
]);
row.getCell(3).numFmt = '#,##0.00';
}
boxAllCells(sheet, start, sheet.rowCount, 8);
sheet.addRow([]);
// 3 & 4 — ageing analyses
start = sheet.rowCount + 1;
this.addAgeingBlock(
sheet,
`3. Age-wise analysis of Inter Branch Adjustment Account - Position as on:${this.quarterEndDate}`,
this.interBranchAgeing
);
sheet.addRow([]);
this.addAgeingBlock(
sheet,
`4. Age-wise analysis of Inter Bank Reconciliation Account - Position as on:${this.quarterEndDate}`,
this.interBankAgeing
);
sheet.addRow([]);
// 5. Unsecured guarantees
start = sheet.rowCount + 1;
const gTitle = sheet.addRow(['5. Unsecured guarantees issued by the bank']);
sheet.mergeCells(`A${gTitle.number}:D${gTitle.number}`);
gTitle.font = { bold: true };
gTitle.getCell(1).fill = solidFill(XL.headerGrey);
const gHead = sheet.addRow([
'Sr. No',
'Name of Borrower',
'Name of Beneficiary',
`Amount Outstanding as on:${this.quarterEndDate}`
]);
gHead.font = { bold: true };
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]);
row.getCell(4).numFmt = '#,##0.00';
}
const gTotal = sheet.addRow(['Total', '', '', this.unsecuredGuaranteesTotal]);
gTotal.font = { bold: true };
gTotal.getCell(4).numFmt = '#,##0.00';
gTotal.getCell(4).fill = solidFill(XL.derivedBlue);
const gNote = sheet.addRow([
'Note : * Give name if borrower/beneficiary is director/top management/related entity. Other guarantees may be consolidated and reported against others.'
]);
sheet.mergeCells(`A${gNote.number}:D${gNote.number}`);
gNote.font = { bold: true, size: 10 };
gNote.alignment = { wrapText: true };
boxAllCells(sheet, start, sheet.rowCount, 4);
}
private addAgeingBlock(sheet: ExcelJS.Worksheet, title: string, rows: typeof RAL_INTER_BRANCH_AGEING): void {
const start = sheet.rowCount + 1;
const titleRow = sheet.addRow([title]);
sheet.mergeCells(`A${titleRow.number}:E${titleRow.number}`);
titleRow.font = { bold: true };
titleRow.getCell(1).fill = solidFill(XL.headerGrey);
const head = sheet.addRow([...this.ageingHeaders]);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const r of rows) {
const row = sheet.addRow([
r.period,
r.noOfDebitEntries,
r.amtOfDebitEntries,
r.noOfCreditEntries,
r.amtOfCreditEntries
]);
row.getCell(3).numFmt = '#,##0.00';
row.getCell(5).numFmt = '#,##0.00';
if (r.period === 'Total') {
row.font = { bold: true };
[
2,
3,
4,
5
].forEach(
(c) => (row.getCell(c).fill = solidFill(XL.derivedBlue))
);
}
}
boxAllCells(sheet, start, sheet.rowCount, 5);
}
private buildAnnexure2(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Annexure 2');
sheet.columns = [
{ width: 10 },
{ width: 62 },
{ width: 20 },
{ width: 20 }
];
addBanner(sheet, 'Annexure 2', 'D');
for (const block of this.sundryDebtors) {
const start = sheet.rowCount + 1;
const t = sheet.addRow([block.title]);
sheet.mergeCells(`A${t.number}:D${t.number}`);
t.font = { bold: true };
t.getCell(1).fill = solidFill(XL.headerGrey);
const s = sheet.addRow([block.subtitle]);
sheet.mergeCells(`A${s.number}:D${s.number}`);
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']);
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]);
row.getCell(3).numFmt = '#,##0.00';
row.getCell(4).numFmt = '#,##0.00';
}
const note = sheet.addRow([this.sundryDebtorsNote]);
sheet.mergeCells(`A${note.number}:D${note.number}`);
note.font = { bold: true, size: 10 };
note.alignment = { wrapText: true };
boxAllCells(sheet, start, sheet.rowCount, 4);
sheet.addRow([]);
}
// Details of Depositors & Borrowers
let start = sheet.rowCount + 1;
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]);
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';
if (line.total) {
row.font = { bold: true };
row.getCell(3).fill = solidFill(XL.derivedBlue);
}
}
boxAllCells(sheet, start, sheet.rowCount, 3);
sheet.addRow([]);
// Complaints
start = sheet.rowCount + 1;
const cTitle = sheet.addRow(['Complaints Received from public']);
sheet.mergeCells(`A${cTitle.number}:B${cTitle.number}`);
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]);
sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(3).numFmt = '#,##0';
}
boxAllCells(sheet, start, sheet.rowCount, 3);
}
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,203 @@
/**
* 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/.
*/
/**
* OSS 2 — Return on Earnings (RoE), return code R071.
*
* Mirrors the RBI CIMS "Form OSS-2_CIMS Template1" workbook sheet-for-sheet:
* General Information / Earnings / Interest Receivable Not Recognized /
* Other Operating Expenses / Signatory Information
*
* All monetary values are Rs in Thousands. A `null` value means the source return
* leaves that cell blank — RBI treats blank and zero differently, so the two are
* never collapsed into one another.
*/
import { OSS_BANK, OSS_SIGNATORIES, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory } from '../oss-return.model';
export type RoeLine = ReturnLine;
export type RoeSignatory = ReturnSignatory;
/** A row on the Interest Receivable Not Recognized sheet. */
export interface RoeInterestNotRecognizedRow {
label: string;
value: number | null;
total?: boolean;
}
/** A line item on the Other Operating Expenses detail sheet. */
export interface RoeOtherExpense {
srNo: number;
particulars: string;
amount: number | null;
}
/** Sheet 1 — General Information */
export const ROE_GENERAL_INFO: ReturnGeneralInfoRow[] = [
{ label: 'Return Name', value: 'Return on Earnings (RoE)' },
{ label: 'Return Code', value: 'R071' },
{ label: 'Name Of Bank', value: OSS_BANK.name },
{ label: 'Bank Code', value: OSS_BANK.bankCode },
{ label: 'RO Name', value: OSS_BANK.roName },
{ label: 'Quarter End Date', value: OSS_BANK.quarterEndDate },
{ label: 'Reporting Frequency', value: OSS_BANK.reportingFrequency },
{ label: 'Audit Flag', value: OSS_BANK.auditFlag },
{ label: 'Date of Audit', value: OSS_BANK.dateOfAudit },
{ label: 'Return Version', value: OSS_BANK.returnVersion },
{ label: 'Start Date', value: OSS_BANK.startDate }
];
/** Sheet 2 — Earnings (items 1 to 33) */
export const ROE_EARNINGS: RoeLine[] = [
{ label: '1. Interest/discount received (2+3+4+5)', level: 0, value: 15069, total: true },
{
label: '2. Interest/discount received on loans and advances (other than from banks)',
level: 0,
value: 12070,
total: true
},
{ label: 'i) Actually received', level: 1, value: 12070 },
{ label: 'ii) Accrued interest on Standard advances', level: 1, value: null },
{ label: '3. Interest on market lending ( to banks, if any)', level: 0, value: null },
{ label: '4. Interest on investments', level: 0, value: 2999 },
{ label: 'i) Interest received /accrued on Standard Investments', level: 1, value: null },
{
label: '5. Interest on additional balance with RBI, inter-bank deposits, placements and credits',
level: 0,
value: null
},
{ label: '6. Other Operating income', level: 0, value: 285, total: true },
{ label: 'i) Fee, Commission, exchange and brokerage', level: 1, value: 51 },
{ label: 'ii) Profit (+) / Loss (-) on forex operations', level: 1, value: null },
{ label: 'iii) Profit (+) / Loss (-) on trading and sale of securities', level: 1, value: null },
{ label: 'iv) Dividend Income', level: 1, value: null },
{ label: 'v) Miscellaneous Income', level: 1, value: 234 },
{ label: '7. Total Operating Income [1+6]', level: 0, value: 15354, total: true },
{ label: '8. Non Operating Income [Capital gains / losses (+)/ (-)]', level: 0, value: 0, total: true },
{ label: 'i) Profit / (Loss) on sale of fixed assets (+) / (-)', level: 1, value: null },
{ label: 'ii) Profit / (Loss) on sale of other assets (+) / (-)', level: 1, value: null },
{ label: 'iii) Other Non-operating Income / (Expenses) (+) / (-)', level: 1, value: null },
{ label: '9. Total income[7+8]', level: 0, value: 15354, total: true },
{ label: '10. Interest paid', level: 0, value: 5659, total: true },
{ label: 'i) On Customer deposits', level: 1, value: 5659 },
{ label: 'ii) On Inter-bank deposits', level: 1, value: null },
{ label: 'iii) On Inter-bank borrowings (including from RBI/FIs)', level: 1, value: null },
{ label: 'iv) Others', level: 1, value: null },
{ label: '11. Operating Expenses', level: 0, value: 3352, total: true },
{ label: 'i) Staff expenses', level: 1, value: 3244 },
{ label: 'ii) Directors fees', level: 1, value: 45 },
{ label: "iii) Auditors' fees", level: 1, value: 63 },
{ label: '12. Other Operating expenses', level: 0, value: 1038, total: true },
{ label: 'i) Rent, taxes and lighting', level: 1, value: null },
{ label: 'ii) Insurance', level: 1, value: 4 },
{ label: 'iii) Law charges', level: 1, value: 0 },
{ label: 'iv) Postage, telegrams and stamps', level: 1, value: 34 },
{ label: 'v) Stationery and printing', level: 1, value: 23 },
{ label: 'vi) Advertisement and publicity', level: 1, value: null },
{ label: "vii) Depreciation on bank's property", level: 1, value: null },
{ label: 'viii) Repairs and maintenance', level: 1, value: null },
{ label: 'ix) Others', level: 1, value: 977, total: true },
{ label: '13. Total operating expenses [10 + 11+ 12]', level: 0, value: 10049, total: true },
{ label: '14. Operating Profit/Loss [7 - 13]', level: 0, value: 5305, total: true },
{
label:
'15. Value adjustment of Securities (marking to market of securities in AFS & HFT) [Only loss is to be reported]',
level: 0,
value: null
},
{ label: '16. Write off', level: 0, value: 0, total: true },
{ label: 'i) Bad Debts written off', level: 1, value: null },
{ label: 'ii) Other assets written off', level: 1, value: null },
{ label: 'iii) Capitalised expenditure written off', level: 1, value: null },
{ label: '17. Provision against risks/ contingencies', level: 0, value: 0, total: true },
{ label: 'i) Provision for loan losses (BDDR, Spl. BDDR, etc.) *', level: 1, value: null },
{ label: 'ii) Provision for depreciation in investments including AFS #', level: 1, value: null },
{
label:
'iii) Other risk provisions towards losses on account of frauds, misappropriations, robberies, claims against the bank, etc.',
level: 1,
value: null
},
{ label: 'iv) Other provisions', level: 1, value: null },
{ label: '18. Net Operating Profit (+)/Loss (-) [14 - 15 - 16 - 17]', level: 0, value: 5305, total: true },
{ label: '19. Non Operating Income [Capital gains]', level: 0, value: 0, total: true },
{ label: '20. Non Operating Income [Capital losses]', level: 0, value: 0, total: true },
{ label: '21. Net Profit (+)/ Loss (-) before taxes - (EBPT) [18 + 19 - 20]', level: 0, value: 5305, total: true },
{ label: '22. Provisions for taxes (if any)', level: 0, value: 500 },
{ label: '23. Net Profit (+)/ Loss (-) after taxes [21 - 22]', level: 0, value: 4805, total: true },
{ label: '24. Balance of previous year’s profit (+) / Accumulated losses (-)', level: 0, value: 5592 },
{ label: '25. Net distributable profit (+) / Accumulated losses (-) [23 + 24]', level: 0, value: 10397, total: true },
{ label: '26. Appropriation of profits', level: 0, value: 10397, total: true },
{ label: 'i) Appropriation towards Statutory Reserves', level: 1, value: 3638 },
{ label: 'ii) Appropriation towards Building Fund', level: 1, value: 519 },
{ label: 'iii) Appropriation towards Dividend Equalisation Fund', level: 1, value: null },
{ label: 'iv) Appropriation towards Other Reserves/ Provisions', level: 1, value: 4058 },
{ label: 'v) Dividend', level: 1, value: 2182 },
{ label: '27. Net interest income', level: 0, value: 9410, total: true },
{ label: '28. Non-interest income', level: 0, value: 285, total: true },
{ label: '29. Non-interest expenditure', level: 0, value: 4390, total: true },
{ label: '30. Average Total Assets', level: 0, value: 796295 },
{ label: '31. Average Earning Assets', level: 0, value: 716407 },
{ label: '32. Working Fund', level: 0, value: 778829 },
{ label: '33. Number of Employee', level: 0, value: 11, count: true }
];
/**
* The two cross-return tie-ups printed at the foot of the Earnings sheet. Both
* reference line items on OSS 1, which is why the page checks them live.
*/
export const ROE_EARNINGS_NOTES: string[] = [
'Note: * The value of {17 (i) Provision for loan losses (BDDR, Spl. BDDR, etc.)} should tally with {12(ii) {BDDR, spl. BDDR} of liabilities in OSS-1.',
'Note: # The value of {17 (ii) Provision for Depreciation in investments including AFS} should tally with {i. For investments (Investment Depreciation Reserve)} of liabilities in OSS-1 .'
];
/** Sheet 3 — Interest Receivable Not Recognized */
export const ROE_INTEREST_NOT_RECOGNIZED: RoeInterestNotRecognizedRow[] = [
{ label: 'i. Interest receivable not recognized during the quarter', value: 2614 },
{ label: 'ii. Balance at the beginning of the quarter', value: 35101 },
{
label:
'iii. Interest received and reversed to income from (i) in the quarter on NPAs upgraded to Performing / accrual basis',
value: 5620
},
{ label: 'Balance at the end of the quarter (i)+(ii)-(iii)', value: 32095, total: true }
];
/** Sheet 4 — Details of Other Operating Expenses (feeds Earnings line 12 ix) */
export const ROE_OTHER_OPERATING_EXPENSES: RoeOtherExpense[] = [
{ srNo: 1, particulars: 'Miscelaneous expenses', amount: 55 },
{ srNo: 2, particulars: 'Entertainment Exps', amount: 24 },
{ srNo: 3, particulars: 'Commission On Mini Deposits', amount: 317 },
{ srNo: 4, particulars: 'AGM expenses', amount: 19 },
{ srNo: 5, particulars: 'Computer Exps', amount: 441 },
{ srNo: 6, particulars: 'Govt sec premium', amount: 0 },
{ srNo: 7, particulars: 'CIC Member Ship', amount: 0 },
{ srNo: 8, particulars: 'transaction expenses', amount: 7 },
{ srNo: 9, particulars: 'Digital Banking', amount: 11 },
{ srNo: 10, particulars: 'GST', amount: 26 },
{ srNo: 11, particulars: 'inventory write off', amount: 0 },
{ srNo: 12, particulars: 'Electricity Expenses', amount: 77 },
{ srNo: 13, particulars: 'Donation to Jila Sahakari', amount: 0 }
];
/** Sheet 5 — Signatory Information. Shared so OSS 1 and OSS 2 always agree. */
export const ROE_SIGNATORIES: RoeSignatory[] = OSS_SIGNATORIES;
@@ -0,0 +1,278 @@
<!--
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="oss-root">
<!-- HEADER -->
<div class="oss-head">
<div class="oss-head-left">
<fa-icon icon="university" class="oss-head-ic"></fa-icon>
<div>
<h2 class="oss-head-t">OSS 2 — {{ returnName }}</h2>
<div class="oss-head-s">
{{ bankName }} &middot; Bank Code <strong>{{ bankCode }}</strong> &middot; Return Code
<strong>{{ returnCode }}</strong> &middot; Quarter ending <strong>{{ quarterEndDate }}</strong>
</div>
</div>
</div>
<div class="oss-head-right">
<span class="oss-audit-pill">{{ auditFlag }}</span>
<button class="oss-btn oss-btn-xls" [disabled]="generating" (click)="generateExcel()">
<fa-icon icon="file-csv"></fa-icon>
{{ generating ? 'Generating…' : 'Generate Excel' }}
</button>
</div>
</div>
<!-- HEADLINE RESULT -->
<div class="oss-balance">
<fa-icon icon="check-circle"></fa-icon>
<span>
Operating Profit <strong>&#8377;{{ operatingProfit | number: '1.2-2' }}</strong> &middot; Net Profit after tax
<strong>&#8377;{{ netProfitAfterTax | number: '1.2-2' }}</strong> (Rs in Thousands)
</span>
</div>
<!-- TABS -->
<div class="oss-tabs">
<button class="oss-tab" [class.active]="activeTab === 'general'" (click)="showTab('general')">
<fa-icon icon="info-circle"></fa-icon> General Information
</button>
<button class="oss-tab" [class.active]="activeTab === 'earnings'" (click)="showTab('earnings')">
<fa-icon icon="university"></fa-icon> Earnings
</button>
<button class="oss-tab" [class.active]="activeTab === 'interest'" (click)="showTab('interest')">
<fa-icon icon="exclamation-triangle"></fa-icon> Interest Not Recognized
</button>
<button class="oss-tab" [class.active]="activeTab === 'other-expenses'" (click)="showTab('other-expenses')">
<fa-icon icon="file-alt"></fa-icon> Other Operating Expenses
</button>
<button class="oss-tab" [class.active]="activeTab === 'signatory'" (click)="showTab('signatory')">
<fa-icon icon="check-circle"></fa-icon> Signatory
</button>
</div>
<!-- GENERAL INFORMATION -->
@if (activeTab === 'general') {
<div class="oss-section">
<div class="oss-sheet-banner">General Information</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl oss-tbl--kv">
<tbody>
@for (item of generalInfo; track item.label) {
<tr>
<th>{{ item.label }}</th>
<td [class.filled]="item.value" [class.audit]="item.label === 'Audit Flag'">{{ item.value }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- EARNINGS -->
@if (activeTab === 'earnings') {
<div class="oss-section">
<div class="oss-sheet-banner">Earnings</div>
<!-- Cross-return tie-ups the footnotes demand -->
<div class="oss-balance" [class.oss-balance--bad]="!allTieUpsMatch">
<fa-icon [icon]="allTieUpsMatch ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span>
{{
allTieUpsMatch
? 'Item 17 tallies with OSS-1 liabilities as the footnotes require.'
: 'Item 17 does not tally with OSS-1 liabilities — see the tie-up check below.'
}}
</span>
</div>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">OSS-1 tie-up check</div>
<table class="oss-tbl">
<thead>
<tr>
<th>OSS-2 item</th>
<th class="ta-r">OSS-2 value</th>
<th>Must tally with</th>
<th class="ta-r">OSS-1 value</th>
<th class="ta-c">Status</th>
</tr>
</thead>
<tbody>
@for (t of oss1TieUps; track t.label) {
<tr>
<td>{{ t.label }}</td>
<td class="ta-r mono">{{ t.oss2Value === null ? '—' : (t.oss2Value | number: '1.2-2') }}</td>
<td>{{ t.oss1Label }}</td>
<td class="ta-r mono">{{ t.oss1Value === null ? '—' : (t.oss1Value | number: '1.2-2') }}</td>
<td class="ta-c">
<span [class]="t.tallies ? 'report-pill report-ok' : 'report-pill report-bad'">
{{ t.tallies ? 'Tallies' : 'Mismatch' }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl">
<thead>
<tr>
<th>Particulars</th>
<th class="ta-r">Rs in Thousand</th>
</tr>
</thead>
<tbody>
@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') }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="oss-notes">
@for (note of earningsNotes; track note) {
<div class="oss-note-line">{{ note }}</div>
}
</div>
</div>
}
<!-- INTEREST RECEIVABLE NOT RECOGNIZED -->
@if (activeTab === 'interest') {
<div class="oss-section">
<div class="oss-sheet-banner">Interest Receivable Not Recognized</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl">
<thead>
<tr>
<th>Interest receivable on NPAs not recognized as income</th>
<th class="ta-r">{{ quarterEndDate }} (Rs in Thousand)</th>
</tr>
</thead>
<tbody>
@for (r of interestNotRecognized; track r.label) {
<tr [class.total-row]="r.total">
<td>{{ r.label }}</td>
<td class="ta-r mono" [class.derived]="r.total">
{{ r.value === null ? '' : (r.value | number: '1.2-2') }}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- OTHER OPERATING EXPENSES -->
@if (activeTab === 'other-expenses') {
<div class="oss-section">
<div class="oss-sheet-banner">Other Operating Expenses</div>
<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>
— {{ otherExpensesTally ? 'tallies' : 'MISMATCH — resolve before filing' }}
</span>
</div>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">Details of Other Operating Expenses &mdash; Rs in Thousand</div>
<table class="oss-tbl">
<thead>
<tr>
<th class="ta-c">Sr No</th>
<th>Particulars</th>
<th class="ta-r">Amount</th>
</tr>
</thead>
<tbody>
@for (e of otherExpenses; track e.srNo) {
<tr>
<td class="ta-c">{{ e.srNo }}</td>
<td>{{ e.particulars }}</td>
<td class="ta-r mono">{{ e.amount === null ? '' : (e.amount | number: '1.2-2') }}</td>
</tr>
}
<tr class="total-row">
<td class="ta-c"></td>
<td>Total</td>
<td class="ta-r mono derived">{{ otherExpensesTotal | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
<p class="oss-foot-note">Feeds Earnings sheet line 12 ix) Others.</p>
</div>
}
<!-- SIGNATORY -->
@if (activeTab === 'signatory') {
<div class="oss-section">
<div class="oss-sheet-banner">Signatory Information</div>
<div class="oss-sign-grid">
@for (p of signatories; track p.heading) {
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">{{ p.heading }}</div>
<table class="oss-tbl oss-tbl--kv">
<tbody>
<tr>
<td>{{ p.signature }}</td>
<th>Signature</th>
</tr>
<tr>
<td>{{ p.name }}</td>
<th>Name</th>
</tr>
<tr>
<td>{{ p.designation }}</td>
<th>Designation</th>
</tr>
<tr>
<td>{{ p.email }}</td>
<th>e-mail ID</th>
</tr>
<tr>
<td class="mono">{{ p.telephoneOffice }}</td>
<th>Tel. No. (O)</th>
</tr>
<tr>
<td class="mono">{{ p.telephoneResidence }}</td>
<th>Tel. No. (R)</th>
</tr>
<tr>
<td>{{ p.place }}</td>
<th>Place</th>
</tr>
<tr>
<td>{{ p.date }}</td>
<th>Date</th>
</tr>
</tbody>
</table>
</div>
}
</div>
</div>
}
<!-- TOAST -->
<div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -0,0 +1,283 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import {
addBanner,
addLineRows,
addSectionHeader,
boxAllCells,
buildGeneralInfoSheet,
buildSignatorySheet,
downloadWorkbook,
newWorkbook,
solidFill,
XL
} from '../oss-workbook';
import { RAL_LIABILITIES } from '../oss1-ral/oss1-ral-data';
import {
ROE_EARNINGS,
ROE_EARNINGS_NOTES,
ROE_GENERAL_INFO,
ROE_INTEREST_NOT_RECOGNIZED,
ROE_OTHER_OPERATING_EXPENSES,
ROE_SIGNATORIES
} from './oss2-roe-data';
type Oss2Tab = 'general' | 'earnings' | 'interest' | 'other-expenses' | 'signatory';
/** One of the two OSS-1 tie-ups printed at the foot of the Earnings sheet. */
interface TieUp {
label: string;
oss2Value: number | null;
oss1Label: string;
oss1Value: number | null;
tallies: boolean;
}
@Component({
selector: 'mifosx-oss2-roe',
templateUrl: './oss2-roe.component.html',
styleUrls: ['../oss-return.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class Oss2RoeComponent {
activeTab: Oss2Tab = 'general';
generalInfo = ROE_GENERAL_INFO;
earnings = ROE_EARNINGS;
earningsNotes = ROE_EARNINGS_NOTES;
interestNotRecognized = ROE_INTEREST_NOT_RECOGNIZED;
otherExpenses = ROE_OTHER_OPERATING_EXPENSES;
signatories = ROE_SIGNATORIES;
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: any;
showTab(tab: Oss2Tab): void {
this.activeTab = tab;
}
private info(label: string): string {
return this.generalInfo.find((g) => g.label === label)?.value ?? '';
}
get returnName(): string {
return this.info('Return Name');
}
get returnCode(): string {
return this.info('Return Code');
}
get bankName(): string {
return this.info('Name Of Bank');
}
get bankCode(): string {
return this.info('Bank Code');
}
get quarterEndDate(): string {
return this.info('Quarter End Date');
}
get auditFlag(): string {
return this.info('Audit Flag');
}
private earningsValue(prefix: string): number | null {
return this.earnings.find((l) => l.label.startsWith(prefix))?.value ?? null;
}
private liabilityValue(prefix: string): number | null {
return RAL_LIABILITIES.find((l) => l.label.startsWith(prefix))?.value ?? null;
}
get netProfitAfterTax(): number {
return this.earningsValue('23.') ?? 0;
}
get operatingProfit(): number {
return this.earningsValue('14.') ?? 0;
}
/** Sum of the detail sheet — must equal Earnings line 12 ix) Others. */
get otherExpensesTotal(): number {
return this.otherExpenses.reduce((sum, e) => sum + (e.amount ?? 0), 0);
}
get otherExpensesLineValue(): number | null {
return this.earningsValue('ix) Others');
}
get otherExpensesTally(): boolean {
return this.otherExpensesTotal === (this.otherExpensesLineValue ?? 0);
}
/**
* The Earnings sheet carries two footnotes requiring 17(i) and 17(ii) to tally
* with OSS-1 liabilities 12(ii) and 12(i). Checked live rather than left to a
* reader spotting it in the fine print.
*/
get oss1TieUps(): TieUp[] {
const rows: TieUp[] = [
{
label: '17 (i) Provision for loan losses (BDDR, Spl. BDDR, etc.)',
oss2Value: this.earningsValue('i) Provision for loan losses'),
oss1Label: 'OSS-1 liabilities 12 (ii) BDDR, Spl BDDR',
oss1Value: this.liabilityValue('ii. BDDR, Spl BDDR'),
tallies: false
},
{
label: '17 (ii) Provision for depreciation in investments including AFS',
oss2Value: this.earningsValue('ii) Provision for depreciation in investments'),
oss1Label: 'OSS-1 liabilities 12 (i) Investment Depreciation Reserve',
oss1Value: this.liabilityValue('i. For investments (Investment Depreciation Reserve)'),
tallies: false
}
];
return rows.map((r) => ({ ...r, tallies: (r.oss2Value ?? 0) === (r.oss1Value ?? 0) }));
}
get allTieUpsMatch(): boolean {
return this.oss1TieUps.every((t) => t.tallies);
}
// ══════════════ Excel generation ══════════════
/** Rebuilds the RBI workbook: one sheet per section, original layout and styling. */
async generateExcel(): Promise<void> {
this.generating = true;
try {
const wb = newWorkbook();
buildGeneralInfoSheet(wb, this.generalInfo);
this.buildEarningsSheet(wb);
this.buildInterestSheet(wb);
this.buildOtherExpensesSheet(wb);
buildSignatorySheet(wb, this.signatories, 'Signatory Information');
await downloadWorkbook(wb, `Form OSS-2_CIMS ${this.returnCode} - ${this.quarterEndDate}.xlsx`);
this.toast(`${this.returnCode} workbook generated for quarter ending ${this.quarterEndDate}`);
} catch {
this.toast('Could not generate the workbook — please retry');
} finally {
this.generating = false;
}
}
private buildEarningsSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Earnings');
sheet.columns = [
{ width: 90 },
{ width: 20 }
];
addBanner(sheet, 'Earnings', 'B');
const first = sheet.rowCount + 1;
addSectionHeader(sheet, '', 'Rs in Thousand');
addLineRows(sheet, this.earnings);
boxAllCells(sheet, first, sheet.rowCount, 2);
sheet.addRow([]);
const notesStart = sheet.rowCount + 1;
for (const note of this.earningsNotes) {
const row = sheet.addRow([note]);
sheet.mergeCells(`A${row.number}:B${row.number}`);
row.font = { bold: true, italic: true, size: 10 };
row.alignment = { wrapText: true };
row.getCell(1).fill = solidFill(XL.headerGrey);
}
boxAllCells(sheet, notesStart, sheet.rowCount, 2);
sheet.views = [{ state: 'frozen', ySplit: 3 }];
}
private buildInterestSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Interest Receivable Not Recog');
sheet.columns = [
{ width: 72 },
{ width: 22 }
];
addBanner(sheet, 'Interest Receivable Not Recognized', 'B');
const start = sheet.rowCount + 1;
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]);
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]);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(1).fill = solidFill(XL.headerGrey);
row.getCell(2).numFmt = '#,##0.00';
if (r.total) {
row.getCell(2).fill = solidFill(XL.derivedBlue);
row.getCell(2).font = { bold: true };
}
}
boxAllCells(sheet, start, sheet.rowCount, 2);
}
private buildOtherExpensesSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Other Operating Expenses');
sheet.columns = [
{ width: 10 },
{ width: 56 },
{ 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']);
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']);
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]);
row.getCell(3).numFmt = '#,##0.00';
}
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);
total.getCell(3).numFmt = '#,##0.00';
total.getCell(3).fill = solidFill(XL.derivedBlue);
boxAllCells(sheet, start, sheet.rowCount, 3);
const ref = sheet.addRow(['', '', 'Refer Earnings Sheet — line 12 ix) Others']);
ref.getCell(3).font = { italic: true, size: 10 };
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,372 @@
<!--
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="oss-root">
<!-- HEADER -->
<div class="oss-head">
<div class="oss-head-left">
<fa-icon icon="university" class="oss-head-ic"></fa-icon>
<div>
<h2 class="oss-head-t">OSS 3 — {{ returnName }}</h2>
<div class="oss-head-s">
{{ bankName }} &middot; Bank Code <strong>{{ bankCode }}</strong> &middot; Return Code
<strong>{{ returnCode }}</strong> &middot; Quarter ending <strong>{{ reportingEndDate }}</strong>
</div>
</div>
</div>
<div class="oss-head-right">
<span class="oss-audit-pill">{{ reportStatus }}</span>
<button class="oss-btn oss-btn-xls" [disabled]="generating" (click)="generateExcel()">
<fa-icon icon="file-csv"></fa-icon>
{{ generating ? 'Generating…' : 'Generate Excel' }}
</button>
</div>
</div>
<!-- HEADLINE ASSET QUALITY -->
<div class="oss-balance" [class.oss-balance--bad]="!totalsAgree">
<fa-icon [icon]="totalsAgree ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span>
Gross NPA <strong>&#8377;{{ grossNpa | number: '1.2-2' }}</strong> ({{ grossNpaRatio | number: '1.2-2' }}%)
&middot; Net NPA <strong>&#8377;{{ netNpa | number: '1.2-2' }}</strong> ({{ netNpaRatio | number: '1.2-2' }}%)
&middot;
{{
totalsAgree
? 'Portfolio Analysis and Classification totals agree'
: 'MISMATCH between Portfolio Analysis and Classification totals — resolve before filing'
}}
</span>
</div>
<!-- TABS -->
<div class="oss-tabs">
@for (t of tabs; track t.id) {
<button class="oss-tab" [class.active]="activeTab === t.id" (click)="showTab(t.id)">
<fa-icon [icon]="t.icon"></fa-icon> {{ t.label }}
</button>
}
</div>
<!-- FILING INFORMATION -->
@if (activeTab === 'filing') {
<div class="oss-section">
<div class="oss-sheet-banner">Filing Information</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl oss-tbl--kv">
<tbody>
@for (item of filingInfo; track item.label) {
<tr>
<th>{{ item.label }}</th>
<td [class.filled]="item.value" [class.audit]="item.label === 'Report status'">{{ item.value }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- 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>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioAnalysis }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioMemo }"></ng-container>
</div>
}
<!-- CLASSIFICATION OF RISK ASSETS -->
@if (activeTab === 'classification') {
<div class="oss-section">
<div class="oss-sheet-banner">Classifcation_Loans_Adv — Classification of Risk Assets</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: classificationPartA }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: nettingItems1 }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: nettingItems2 }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: classificationMemo }"></ng-container>
</div>
}
<!-- MOVEMENT OF LOANS -->
@if (activeTab === 'movement') {
<div class="oss-section">
<div class="oss-sheet-banner">Movement_of_loans — Change in Asset Quality Profile and Recoveries of NPAs</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: movementPart1 }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: movementPart2 }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: movementPart3 }"></ng-container>
</div>
}
<!-- OTHER RISK ASSETS -->
@if (activeTab === 'other-risk') {
<div class="oss-section">
<div class="oss-sheet-banner">StatementOtherRiskAssets — Other Risk Assets and Exposures</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: otherRiskOnBs }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: otherRiskOffBs }"></ng-container>
</div>
}
<!-- TOP IMPAIRED CREDITS -->
@if (activeTab === 'impaired') {
<div class="oss-section">
<div class="oss-sheet-banner">TopImpairedCredits — Top Impaired Credits</div>
<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>
<table class="oss-tbl">
<thead>
<tr>
<th>PAN of Borrower</th>
<th>Name of Borrower</th>
<th class="ta-r">Loans &amp; Advances</th>
<th class="ta-r">Funded Exposure</th>
<th class="ta-r">Interest in arrear</th>
<th class="ta-r">% to Total Advances</th>
<th class="ta-r">% to Capital Funds</th>
<th>Asset Classification</th>
<th class="ta-r">Loss Provision held</th>
</tr>
</thead>
<tbody>
@for (r of impairedCredits; track $index) {
<tr>
<td class="mono">{{ r.pan }}</td>
<td>{{ r.name }}</td>
<td class="ta-r mono">{{ r.loansAdvances | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.funded | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.interestInArrear | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.pctTotalAdvances | number: '1.2-2' }}%</td>
<td class="ta-r mono">{{ r.pctCapitalFunds | number: '1.2-2' }}%</td>
<td>
<span class="report-pill" [class.report-bad]="r.assetClassification !== 'Standard'">
{{ r.assetClassification }}
</span>
</td>
<td class="ta-r mono">{{ r.lossProvisionHeld | number: '1.2-2' }}</td>
</tr>
}
<tr class="total-row">
<td colspan="3">Total ({{ impairedCredits.length }} borrowers)</td>
<td class="ta-r mono derived">{{ impairedFundedTotal | number: '1.2-2' }}</td>
<td class="ta-r mono derived">{{ impairedInterestTotal | number: '1.2-2' }}</td>
<td colspan="3"></td>
<td class="ta-r mono derived">{{ impairedProvisionTotal | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
@for (t of recoveryTables; track t.title) {
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: t }"></ng-container>
}
</div>
}
<!-- EXPOSURE LIMITS -->
@if (activeTab === 'exposure') {
<div class="oss-section">
<div class="oss-sheet-banner">ExposureLimits — Single and Group Borrower Exposure Limit</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: capitalPreviousMarch }"></ng-container>
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">
Table 2: Statement on Large Exposure &mdash; Single Borrower (in excess of 15% of Tier 1 capital as on
previous March, or a minimum of 20 large credits)
</div>
<table class="oss-tbl">
<thead>
<tr>
<th>PAN of Borrower</th>
<th>Name of Borrower</th>
<th class="ta-r">Limit sanctioned</th>
<th class="ta-r">% to Total Advances</th>
<th class="ta-r">Outstanding Balance</th>
<th class="ta-r">% to Tier 1 capital</th>
<th>Asset Classification</th>
</tr>
</thead>
<tbody>
@for (r of largeExposures; track $index) {
<tr>
<td class="mono">{{ r.pan }}</td>
<td>{{ r.name }}</td>
<td class="ta-r mono">{{ r.limitSanctioned | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.pctTotalAdvances | number: '1.2-2' }}%</td>
<td class="ta-r mono">{{ r.outstandingBalance | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.pctTier1Capital | number: '1.2-2' }}%</td>
<td>
<span class="report-pill report-ok">{{ r.assetClassification }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="oss-notes">
@for (note of exposureNotes; track note) {
<div class="oss-note-line">{{ note }}</div>
}
</div>
</div>
}
<!-- SECURITY PORTFOLIO -->
@if (activeTab === 'securities') {
<div class="oss-section">
<div class="oss-sheet-banner">QualitySecurityPortoflio — Quality of Security Portfolio</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: investmentQuality }"></ng-container>
<div class="oss-sheet-banner">OtherNonSLRSecurity — Other Details of Non-SLR Securities</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: nonSlrSecurities }"></ng-container>
</div>
}
<!-- SECTORAL CREDIT -->
@if (activeTab === 'sectoral') {
<div class="oss-section">
<div class="oss-sheet-banner">SectoralCredit — Sectoral Credit</div>
<div class="oss-balance" [class.oss-balance--bad]="!sectoralAgreesWithClosing">
<fa-icon [icon]="sectoralAgreesWithClosing ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span>
@if (sectoralAgreesWithClosing) {
Sectoral totals agree with the Classification statement.
} @else {
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>).
@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.
}
}
</span>
</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: sectoralCredit }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: sectoralMemo }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: segmentAnalysis }"></ng-container>
</div>
}
<!-- INDUSTRY BREAKUP -->
@if (activeTab === 'industry') {
<div class="oss-section">
<div class="oss-sheet-banner">IndustryBreakup — Industry Breakup</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: industryBreakup }"></ng-container>
</div>
}
<!-- SENSITIVE SECTOR -->
@if (activeTab === 'sensitive') {
<div class="oss-section">
<div class="oss-sheet-banner">SensitiveSector — Sensitive Sector</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: capitalMarket }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: realEstate }"></ng-container>
</div>
}
<!-- SIGNATORY -->
@if (activeTab === 'signatory') {
<div class="oss-section">
<div class="oss-sheet-banner">Signatory — Authorised Signatory</div>
<div class="oss-sign-grid">
@for (p of signatories; track p.heading) {
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">{{ p.heading }}</div>
<table class="oss-tbl oss-tbl--kv">
<tbody>
<tr>
<td>{{ p.signature }}</td>
<th>Signature</th>
</tr>
<tr>
<td>{{ p.name }}</td>
<th>Name</th>
</tr>
<tr>
<td>{{ p.designation }}</td>
<th>Designation</th>
</tr>
<tr>
<td>{{ p.email }}</td>
<th>e-mail ID</th>
</tr>
<tr>
<td class="mono">{{ p.telephoneOffice }}</td>
<th>Tele. No. (O)</th>
</tr>
<tr>
<td class="mono">{{ p.telephoneResidence }}</td>
<th>Tele. No. (R)</th>
</tr>
<tr>
<td>{{ p.place }}</td>
<th>Place</th>
</tr>
<tr>
<td>{{ p.date }}</td>
<th>Date</th>
</tr>
</tbody>
</table>
</div>
}
</div>
</div>
}
<!-- Shared renderer for every multi-column statement in this return. -->
<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>}
</div>
@if (t.subtitle) {
<div class="oss-tbl-sub">{{ t.subtitle }}</div>
}
<table class="oss-tbl">
<thead>
<tr>
<th class="oss-col-label">Particulars</th>
@for (c of t.columns; track $index) {
<th class="ta-r">{{ c }}</th>
}
</tr>
</thead>
<tbody>
@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 (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>
}
}
</tr>
}
</tbody>
</table>
</div>
@if (t.notes?.length) {
<div class="oss-notes">
@for (note of t.notes; track note) {
<div class="oss-note-line">{{ note }}</div>
}
</div>
}
</ng-template>
<!-- TOAST -->
<div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -0,0 +1,470 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import { MatrixTable } from '../oss-return.model';
import {
addBanner,
addMatrixTable,
boxAllCells,
buildGeneralInfoSheet,
buildSignatorySheet,
downloadWorkbook,
newWorkbook,
solidFill,
XL
} from '../oss-workbook';
import {
RAQ_CAPITAL_MARKET,
RAQ_CAPITAL_MEMO,
RAQ_CAPITAL_PREVIOUS_MARCH,
RAQ_CLASSIFICATION_MEMO,
RAQ_CLASSIFICATION_PART_A,
RAQ_EXPOSURE_NOTES,
RAQ_FILING_INFO,
RAQ_IMPAIRED_CREDITS,
RAQ_INDUSTRY_BREAKUP,
RAQ_INVESTMENT_QUALITY,
RAQ_LARGE_EXPOSURES,
RAQ_MOVEMENT_PART_1,
RAQ_MOVEMENT_PART_2,
RAQ_MOVEMENT_PART_3,
RAQ_NETTING_ITEMS_1,
RAQ_NETTING_ITEMS_2,
RAQ_NON_SLR_SECURITIES,
RAQ_OTHER_RISK_OFF_BS,
RAQ_OTHER_RISK_ON_BS,
RAQ_PORTFOLIO_ANALYSIS,
RAQ_PORTFOLIO_MEMO,
RAQ_REAL_ESTATE,
RAQ_RECOVERY_TABLES,
RAQ_SECTORAL_CREDIT,
RAQ_SECTORAL_MEMO,
RAQ_SEGMENT_ANALYSIS,
RAQ_SIGNATORIES
} from './oss3-raq-data';
type Oss3Tab =
| 'filing'
| 'portfolio'
| 'classification'
| 'movement'
| 'other-risk'
| 'impaired'
| 'exposure'
| 'securities'
| 'sectoral'
| 'industry'
| 'sensitive'
| 'signatory';
@Component({
selector: 'mifosx-oss3-raq',
templateUrl: './oss3-raq.component.html',
styleUrls: ['../oss-return.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class Oss3RaqComponent {
activeTab: Oss3Tab = 'filing';
/** Tab strip definition — keeps the template free of a dozen near-identical buttons. */
readonly tabs: { id: Oss3Tab; label: string; icon: string }[] = [
{ id: 'filing', label: 'Filing Information', icon: 'info-circle' },
{ id: 'portfolio', label: 'Portfolio Analysis', icon: 'chart-pie' },
{ id: 'classification', label: 'Classification of Risk Assets', icon: 'university' },
{ id: 'movement', label: 'Movement of Loans', icon: 'exchange-alt' },
{ id: 'other-risk', label: 'Other Risk Assets', icon: 'exclamation-triangle' },
{ id: 'impaired', label: 'Top Impaired Credits', icon: 'file-alt' },
{ id: 'exposure', label: 'Exposure Limits', icon: 'users' },
{ id: 'securities', label: 'Security Portfolio', icon: 'coins' },
{ id: 'sectoral', label: 'Sectoral Credit', icon: 'chart-pie' },
{ id: 'industry', label: 'Industry Breakup', icon: 'file-alt' },
{ id: 'sensitive', label: 'Sensitive Sector', icon: 'shield-alt' },
{ id: 'signatory', label: 'Signatory', icon: 'check-circle' }
];
filingInfo = RAQ_FILING_INFO;
portfolioAnalysis = RAQ_PORTFOLIO_ANALYSIS;
portfolioMemo = RAQ_PORTFOLIO_MEMO;
classificationPartA = RAQ_CLASSIFICATION_PART_A;
nettingItems1 = RAQ_NETTING_ITEMS_1;
nettingItems2 = RAQ_NETTING_ITEMS_2;
classificationMemo = RAQ_CLASSIFICATION_MEMO;
movementPart1 = RAQ_MOVEMENT_PART_1;
movementPart2 = RAQ_MOVEMENT_PART_2;
movementPart3 = RAQ_MOVEMENT_PART_3;
otherRiskOnBs = RAQ_OTHER_RISK_ON_BS;
otherRiskOffBs = RAQ_OTHER_RISK_OFF_BS;
capitalMemo = RAQ_CAPITAL_MEMO;
recoveryTables = RAQ_RECOVERY_TABLES;
impairedCredits = RAQ_IMPAIRED_CREDITS;
capitalPreviousMarch = RAQ_CAPITAL_PREVIOUS_MARCH;
largeExposures = RAQ_LARGE_EXPOSURES;
exposureNotes = RAQ_EXPOSURE_NOTES;
investmentQuality = RAQ_INVESTMENT_QUALITY;
nonSlrSecurities = RAQ_NON_SLR_SECURITIES;
sectoralCredit = RAQ_SECTORAL_CREDIT;
sectoralMemo = RAQ_SECTORAL_MEMO;
segmentAnalysis = RAQ_SEGMENT_ANALYSIS;
industryBreakup = RAQ_INDUSTRY_BREAKUP;
capitalMarket = RAQ_CAPITAL_MARKET;
realEstate = RAQ_REAL_ESTATE;
signatories = RAQ_SIGNATORIES;
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: any;
showTab(tab: Oss3Tab): void {
this.activeTab = tab;
}
private info(label: string): string {
return this.filingInfo.find((g) => g.label === label)?.value ?? '';
}
get returnName(): string {
return this.info('Return Name');
}
get returnCode(): string {
return this.info('Return Code');
}
get bankName(): string {
return this.info('Name of reporting institution');
}
get bankCode(): string {
return this.info('Bank Code');
}
get reportingEndDate(): string {
return this.info('Reporting end date');
}
get reportStatus(): string {
return this.info('Report status');
}
private matrixValue(table: MatrixTable, prefix: string, col: number): number | null {
return table.rows.find((r) => r.label.startsWith(prefix))?.values[col] ?? null;
}
get grossNpa(): number {
return this.matrixValue(this.nettingItems2, 'Gross Non Performing Advances', 0) ?? 0;
}
get grossNpaRatio(): number {
return this.matrixValue(this.nettingItems2, 'Gross Non Performing Advances Ratio', 0) ?? 0;
}
get netNpa(): number {
return this.matrixValue(this.nettingItems2, 'Net Non Performing Advances', 0) ?? 0;
}
get netNpaRatio(): number {
return this.matrixValue(this.nettingItems2, 'Net Non Performing Advances Ratio', 0) ?? 0;
}
/**
* Portfolio Analysis TOTAL must equal Classification TOTAL — the two statements
* describe the same book from different angles and RBI validates them against
* each other, so a divergence is surfaced rather than left to be spotted.
*/
get portfolioTotal(): number {
return this.matrixValue(this.portfolioAnalysis, 'TOTAL (A + B)', 3) ?? 0;
}
get classificationTotal(): number {
return this.matrixValue(this.classificationPartA, 'TOTAL', 0) ?? 0;
}
get totalsAgree(): boolean {
return this.portfolioTotal === this.classificationTotal;
}
/**
* Sectoral Credit reports its own gross advances and NPA figures, which should
* describe the same closing book as the Classification statement. In this filing
* they instead match the *opening* balances from Movement Part 1/2, so the
* divergence is shown rather than buried.
*/
get sectoralGross(): number {
return this.matrixValue(this.sectoralCredit, 'I. Gross Advances', 0) ?? 0;
}
get sectoralNpa(): number {
return this.matrixValue(this.sectoralCredit, 'I. Gross Advances', 1) ?? 0;
}
get movementOpeningGross(): number {
return this.matrixValue(this.movementPart1, 'A. Loans and advances at the beginning', 4) ?? 0;
}
get movementOpeningNpa(): number {
return this.matrixValue(this.movementPart2, 'A. NPAs at the beginning', 3) ?? 0;
}
get sectoralAgreesWithClosing(): boolean {
return this.sectoralGross === this.classificationTotal && this.sectoralNpa === this.grossNpa;
}
get sectoralMatchesOpening(): boolean {
return this.sectoralGross === this.movementOpeningGross && this.sectoralNpa === this.movementOpeningNpa;
}
/** Table 5 footer totals, summed rather than hardcoded so they cannot drift. */
get impairedFundedTotal(): number {
return this.impairedCredits.reduce((s, r) => s + (r.funded ?? 0), 0);
}
get impairedInterestTotal(): number {
return this.impairedCredits.reduce((s, r) => s + (r.interestInArrear ?? 0), 0);
}
get impairedProvisionTotal(): number {
return this.impairedCredits.reduce((s, r) => s + (r.lossProvisionHeld ?? 0), 0);
}
// ══════════════ Excel generation ══════════════
/** Rebuilds the RBI iFILE workbook: one sheet per statement, original layout. */
async generateExcel(): Promise<void> {
this.generating = true;
try {
const wb = newWorkbook();
buildGeneralInfoSheet(wb, this.filingInfo);
this.sheetOf(wb, 'PortfolioAnalysis', [
this.portfolioAnalysis,
this.portfolioMemo
]);
this.sheetOf(wb, 'Classifcation_Loans_Adv', [
this.classificationPartA,
this.nettingItems1,
this.nettingItems2,
this.classificationMemo
]);
this.sheetOf(wb, 'Movement_of_loans', [
this.movementPart1,
this.movementPart2,
this.movementPart3
]);
this.sheetOf(wb, 'StatementOtherRiskAssets', [
this.otherRiskOnBs,
this.otherRiskOffBs
]);
this.buildImpairedCreditsSheet(wb);
this.buildExposureLimitsSheet(wb);
this.sheetOf(wb, 'QualitySecurityPortoflio', [this.investmentQuality]);
this.sheetOf(wb, 'OtherNonSLRSecurity', [this.nonSlrSecurities]);
this.sheetOf(wb, 'SectoralCredit', [
this.sectoralCredit,
this.sectoralMemo,
this.segmentAnalysis
]);
this.sheetOf(wb, 'IndustryBreakup', [this.industryBreakup]);
this.sheetOf(wb, 'SensitiveSector', [
this.capitalMarket,
this.realEstate
]);
buildSignatorySheet(wb, this.signatories, 'Signatory');
await downloadWorkbook(wb, `R32312 OSS 3 ${this.returnCode} - ${this.reportingEndDate}.xlsx`);
this.toast(`${this.returnCode} workbook generated for quarter ending ${this.reportingEndDate}`);
} catch {
this.toast('Could not generate the workbook — please retry');
} finally {
this.generating = false;
}
}
/** One worksheet holding a stack of matrix statements. */
private sheetOf(wb: ExcelJS.Workbook, name: string, tables: MatrixTable[]): void {
const sheet = wb.addWorksheet(name);
const widest = Math.max(...tables.map((t) => t.columns.length));
sheet.columns = [
{ width: 62 },
...Array.from({ length: widest }, () => ({ width: 18 }))
];
addBanner(sheet, name, this.colLetter(widest + 1));
for (const table of tables) {
addMatrixTable(sheet, table);
}
}
private colLetter(index: number): string {
let n = index;
let s = '';
while (n > 0) {
const rem = (n - 1) % 26;
s = String.fromCharCode(65 + rem) + s;
n = Math.floor((n - 1) / 26);
}
return s || 'A';
}
private buildImpairedCreditsSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('TopImpairedCredits');
sheet.columns = [
{ width: 16 },
{ width: 40 },
{ width: 16 },
{ width: 16 },
{ width: 16 },
{ width: 16 },
{ width: 18 },
{ width: 18 },
{ width: 18 }
];
addBanner(sheet, 'TopImpairedCredits - Top Impaired Credits', 'I');
addMatrixTable(sheet, this.capitalMemo);
for (const t of this.recoveryTables) {
addMatrixTable(sheet, t);
}
const start = sheet.rowCount + 1;
const title = sheet.addRow(['Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs']);
sheet.mergeCells(title.number, 1, title.number, 9);
title.font = { bold: true };
title.getCell(1).fill = solidFill(XL.headerGrey);
const head = sheet.addRow([
'PAN of Borrower',
'Name of Borrower',
'Loans & Advances',
'Funded Exposure',
'Interest in arrear',
'% to Total Advances',
'% to Capital Funds of the Bank',
'Asset Classification',
'Loss Provision held'
]);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const r of this.impairedCredits) {
const row = sheet.addRow([
r.pan,
r.name,
r.loansAdvances,
r.funded,
r.interestInArrear,
r.pctTotalAdvances,
r.pctCapitalFunds,
r.assetClassification,
r.lossProvisionHeld
]);
[
3,
4,
5,
9
].forEach((c) => (row.getCell(c).numFmt = '#,##0.00'));
[
6,
7
].forEach((c) => (row.getCell(c).numFmt = '0.00"%"'));
}
const total = sheet.addRow([
'Total',
'',
null,
this.impairedFundedTotal,
this.impairedInterestTotal,
null,
null,
'',
this.impairedProvisionTotal
]);
total.font = { bold: true };
[
4,
5,
9
].forEach((c) => {
total.getCell(c).numFmt = '#,##0.00';
total.getCell(c).fill = solidFill(XL.derivedBlue);
});
boxAllCells(sheet, start, sheet.rowCount, 9);
}
private buildExposureLimitsSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('ExposureLimits');
sheet.columns = [
{ width: 16 },
{ width: 46 },
{ width: 18 },
{ width: 18 },
{ width: 20 },
{ width: 22 },
{ width: 20 }
];
addBanner(sheet, 'ExposureLimits - Single and Group Borrower Exposure Limit', 'G');
addMatrixTable(sheet, this.capitalPreviousMarch);
const start = sheet.rowCount + 1;
const title = sheet.addRow(['Table 2: STATEMENT ON LARGE EXPOSURE - SINGLE BORROWER']);
sheet.mergeCells(title.number, 1, title.number, 7);
title.font = { bold: true };
title.getCell(1).fill = solidFill(XL.headerGrey);
const sub = sheet.addRow([
'In excess of 15% of banks Tier 1 capital as on previous March or a minimum of 20 large credits'
]);
sheet.mergeCells(sub.number, 1, sub.number, 7);
sub.font = { bold: true, size: 10 };
sub.getCell(1).fill = solidFill(XL.headerGrey);
const head = sheet.addRow([
'PAN of Borrower',
'Name of Borrower',
'Limit sanctioned',
'% to Total Advances',
'Outstanding Balance',
'% to Tier 1 capital of the Bank',
'Asset Classification'
]);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const r of this.largeExposures) {
const row = sheet.addRow([
r.pan,
r.name,
r.limitSanctioned,
r.pctTotalAdvances,
r.outstandingBalance,
r.pctTier1Capital,
r.assetClassification
]);
[
3,
5
].forEach((c) => (row.getCell(c).numFmt = '#,##0.00'));
[
4,
6
].forEach((c) => (row.getCell(c).numFmt = '0.00"%"'));
}
boxAllCells(sheet, start, sheet.rowCount, 7);
for (const note of this.exposureNotes) {
const n = sheet.addRow([note]);
sheet.mergeCells(n.number, 1, n.number, 7);
n.font = { bold: true, size: 9 };
n.alignment = { wrapText: true };
n.getCell(1).fill = solidFill(XL.headerGrey);
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
@@ -24,6 +24,22 @@ const routes: Routes = [
path: 'rbi/aml', path: 'rbi/aml',
component: AmlRulesComponent, component: AmlRulesComponent,
data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' } data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' }
},
{
path: 'rbi/oss1',
// Lazy — the ExcelJS workbook builder should not weigh down the eager bundle.
loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent),
data: { title: 'OSS 1 — Return on Assets and Liabilities', breadcrumb: 'OSS 1 (RAL)' }
},
{
path: 'rbi/oss2',
loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent),
data: { title: 'OSS 2 — Return on Earnings', breadcrumb: 'OSS 2 (RoE)' }
},
{
path: 'rbi/oss3',
loadComponent: () => import('./oss3-raq/oss3-raq.component').then((m) => m.Oss3RaqComponent),
data: { title: 'OSS 3 — Return on Asset Quality', breadcrumb: 'OSS 3 (RAQ-UCB)' }
} }
]) ])
]; ];
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260731', 'version': '260801',
'hash': 'fd28788d' 'hash': 'b9b37397'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };