Compare commits

...
3 Commits
Author SHA1 Message Date
nitesh 8ac4010967 feat(investment): add portfolio allocation targets and interest receivable/post/receive lifecycle
Adds a Portfolio Allocation (Target vs Actual %) section to the Investment
Dashboard, and reworks Investment Interest into the full accrual lifecycle:
Interest Receivable, FD/Govt./Bond Interest Calculation & Post, and Interest
Receive, plus the existing registers.

Interest Calculation & Post now tracks cumulative posted/received amounts
per holding instead of recomputing full accrued interest on every click
(which previously double-counted on repeat posting). Holdings classified
Non-Performing (Sub-Standard/Doubtful/Loss) are excluded from accrual
posting per RBI norms — their interest accrues in memo/suspense only and is
recognised solely on actual cash receipt via Interest Receive.

Also bumps the production initial-bundle budget in angular.json (13/14mb ->
15/17mb) since the growing set of eagerly-loaded feature modules (Investment,
NPA, Reconciliation, GL Reports, OSS Returns) pushed the build past the old
limit.
2026-08-01 17:56:44 +05:30
nitesh 1c125d97c5 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-01 13:15:57 +05:30
nitesh c935e2a62a feat(reconciliation): build full 16-screen Reconciliation module
Wires every item in the Reconciliation mega-menu (Core & Books, Bank &
Clearing, Digital & Payments, Instruments & Members, Compliance &
Financials) to a working screen, mirroring the NPA module's pattern: one
route-param-driven component over a shared two-sided (Side A vs Side B)
matching model, with pagination and a "Mark as Reconciled" action on every
screen.

Access is split by real-world scope: activity that happens at a branch
(vouchers, cheques, digital transactions, share members, fixed assets) is
branch-wise with the same Head-Office/branch login behavior as NPA; activity
with no branch dimension in real banking (GL/P&L consolidation, treasury's
own bank accounts, statutory tax filing, vendor procurement, inter-branch
netting) is Head-Office-only, with branch logins shown a restricted notice
instead of the data.

Also adds the .menu-item-live "working" indicator to mat-menu-item buttons
(same convention as .gl-link--live) and applies it across the reconciliation
menu and the pre-existing DD/Pay Order Reconciliation link.
2026-08-01 13:15:17 +05:30
17 changed files with 2034 additions and 76 deletions
+2 -2
View File
@@ -69,8 +69,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "13mb",
"maximumError": "14mb"
"maximumWarning": "15mb",
"maximumError": "17mb"
},
{
"type": "anyComponentStyle",
+2
View File
@@ -68,6 +68,7 @@ import { GlReportsModule } from './gl-reports/gl-reports.module';
import { SftModule } from './sft/sft.module';
import { InvestmentModule } from './investment/investment.module';
import { NpaModule } from './npa/npa.module';
import { ReconciliationModule } from './reconciliation/reconciliation.module';
import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module';
@@ -178,6 +179,7 @@ export function HttpLoaderFactory(http: HttpClient) {
SftModule,
InvestmentModule,
NpaModule,
ReconciliationModule,
DigitalModule,
LetterMgmtModule,
AccountsModule,
+17 -17
View File
@@ -1306,27 +1306,27 @@
<!-- Core / Books -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core &amp; Books</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/voucher">
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Voucher Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
GL Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation
</button>
@@ -1335,19 +1335,19 @@
<!-- Bank & Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Bank &amp; Clearing</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/bank-reco">
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Bank Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Based Bank Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation
</button>
@@ -1356,15 +1356,15 @@
<!-- Digital / Payments -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Digital &amp; Payments</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/udir">
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
UDIR (IMPS / UPI / ATM)
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation
</button>
@@ -1373,11 +1373,11 @@
<!-- Instruments & Members -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Instruments &amp; Members</div>
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0" class="menu-item-live">
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation
</button>
@@ -1386,11 +1386,11 @@
<!-- Compliance & Financials -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Financials</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/invoice-payment">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Invoice Payment
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/tax-reco">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Tax Reconciliation
</button>
+20
View File
@@ -677,6 +677,26 @@ $gray-700: #334155;
opacity: 0.7;
}
// Marks a `mat-menu-item` as actually wired to a working screen/dialog (vs a still-unwired
// placeholder) — same "green dot" convention as .gl-link--live, positioned absolutely since
// mat-menu-item's internal layout isn't a plain flex row we control directly.
[mat-menu-item].menu-item-live {
position: relative;
&::after {
content: '';
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: #16a34a;
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
}
}
// ===== Nav Dropdown Menus =====
::ng-deep .nav-dropdown {
border-radius: 8px !important;
@@ -78,6 +78,58 @@
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div>
<table class="pg-table">
<thead>
<tr>
<th>Instrument Type</th>
<th class="num">Target %</th>
<th class="num">Actual %</th>
<th class="num">Actual Value</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of allocationVsTarget; track a.type) {
<tr>
<td>{{ a.label }}</td>
<td class="num">{{ a.targetPct }}%</td>
<td class="num">{{ a.actualPct }}%</td>
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
<td
class="num"
[class.col-out]="a.statusLabel !== 'Within Target'"
[class.col-in]="a.statusLabel === 'Within Target'"
>
{{ a.variancePct > 0 ? '+' : '' }}{{ a.variancePct }}%
</td>
<td>
<span
class="inv-badge"
[class.inv-badge--active]="a.statusLabel === 'Within Target'"
[class.inv-badge--sold]="a.statusLabel === 'Over-Allocated'"
[class.inv-badge--written_off]="a.statusLabel === 'Under-Allocated'"
>
{{ a.statusLabel }}
</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">100%</td>
<td class="num">100%</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
@if (maturingSoon.length === 0) {
@@ -14,6 +14,7 @@ import {
INSTRUMENT_LABELS,
InstrumentType,
InvestmentHolding,
TARGET_ALLOCATION_PCT,
accruedInterest,
daysToMaturity,
maturityAmount
@@ -29,6 +30,16 @@ interface InstrumentSummary {
maturityValue: number;
}
interface AllocationRow {
type: InstrumentType;
label: string;
targetPct: number;
actualPct: number;
actualValue: number;
variancePct: number;
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated';
}
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
@Component({
@@ -79,6 +90,30 @@ export class InvestmentDashboardComponent {
});
}
/** Board-approved policy target % per instrument type vs the portfolio's actual allocation
* (by invested/face value) — a ±2 percentage-point band is treated as "Within Target" since
* a treasury desk can't rebalance to the exact decimal every day. */
get allocationVsTarget(): AllocationRow[] {
const total = this.totalFaceValue;
return this.types.map(({ type }) => {
const value = this.holdingsOf(type).reduce((s, h) => s + h.faceValue, 0);
const actualPct = total ? (value / total) * 100 : 0;
const targetPct = TARGET_ALLOCATION_PCT[type];
const variancePct = +(actualPct - targetPct).toFixed(1);
const statusLabel: AllocationRow['statusLabel'] =
Math.abs(variancePct) <= 2 ? 'Within Target' : variancePct > 0 ? 'Over-Allocated' : 'Under-Allocated';
return {
type,
label: INSTRUMENT_LABELS[type],
targetPct,
actualPct: +actualPct.toFixed(1),
actualValue: value,
variancePct,
statusLabel
};
});
}
get totalHoldings(): number {
return ALL_HOLDINGS.length;
}
+114 -6
View File
@@ -22,6 +22,7 @@ export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' |
export type TxnType =
| 'PURCHASE'
| 'INTEREST'
| 'INTEREST_RECEIVED'
| 'DIVIDEND'
| 'SELL'
| 'REDEEM'
@@ -38,6 +39,32 @@ export const INSTRUMENT_LABELS: Record<InstrumentType, string> = {
SHARE: 'Share'
};
/** RBI investment-provisioning classification. A holding with no `provisionStatus` (or
* explicitly 'STANDARD') is performing normally. Once classified Sub-Standard/Doubtful/Loss —
* a Non-Performing Investment (NPI), the loan-book equivalent of an NPA — interest can no
* longer be recognised on accrual basis (see canAccrueInterest()) and a provision must be held
* against the investment (see provisionRequiredInv()). */
export type InvestmentProvisionStatus = 'STANDARD' | 'SUB_STANDARD' | 'DOUBTFUL' | 'LOSS';
export const PROVISION_STATUS_LABELS: Record<InvestmentProvisionStatus, string> = {
STANDARD: 'Standard',
SUB_STANDARD: 'Sub-Standard (NPI)',
DOUBTFUL: 'Doubtful (NPI)',
LOSS: 'Loss (NPI)'
};
/** Board-approved portfolio-policy target — % of the total investment corpus each instrument
* type should hold. Compared against actual holdings on the Dashboard so a treasury desk can
* see at a glance where the book has drifted from policy. */
export const TARGET_ALLOCATION_PCT: Record<InstrumentType, number> = {
FD: 15,
GOVT_SECURITY: 35,
NON_SLR: 10,
BOND: 20,
MUTUAL_FUND: 12,
SHARE: 8
};
export interface InvestmentHolding {
certNo: string;
instrumentType: InstrumentType;
@@ -57,6 +84,12 @@ export interface InvestmentHolding {
crToAc: string;
brokerCode: string;
status: HoldingStatus;
/** Absent/'STANDARD' for a healthy holding. Drives canAccrueInterest() and provisioning. */
provisionStatus?: InvestmentProvisionStatus;
/** Cumulative interest posted (booked as an accrual receivable/income entry) to date. */
interestPosted?: number;
/** Cumulative interest actually received in cash to date. */
interestReceived?: number;
}
export interface InvestmentTxn {
@@ -109,6 +142,67 @@ export function daysToMaturity(h: InvestmentHolding, asOnStr: string): number {
return Math.round((mat - asOn) / (24 * 3600 * 1000));
}
// -------------------- Provisioning & interest-lifecycle helpers --------------------
export function isNpi(h: InvestmentHolding): boolean {
return (h.provisionStatus ?? 'STANDARD') !== 'STANDARD';
}
export function provisionRatePctInv(status: InvestmentProvisionStatus): number {
switch (status) {
case 'STANDARD':
return 0;
case 'SUB_STANDARD':
return 25;
case 'DOUBTFUL':
return 50;
case 'LOSS':
return 100;
}
}
/** Provision required against the investment itself (principal), same idea as NPA provisioning
* on a loan book — computed on face value since that is this module's carrying-value figure. */
export function provisionRequiredInv(h: InvestmentHolding): number {
return Math.round(h.faceValue * (provisionRatePctInv(h.provisionStatus ?? 'STANDARD') / 100));
}
/** Interest can only be recognised on an accrual basis (posted as a receivable/income entry)
* while the investment is Standard. Once it is classified Non-Performing (Sub-Standard/
* Doubtful/Loss), RBI income-recognition norms require interest to move to cash basis — i.e.
* it is recognised only when actually received, never posted as an accrual receivable. */
export function canAccrueInterest(h: InvestmentHolding): boolean {
return !isNpi(h);
}
/** Interest still to be calculated & posted this cycle. Always 0 for an NPI holding — posting
* an accrual on a non-performing investment would overstate income. */
export function interestToPost(h: InvestmentHolding, asOnStr: string): number {
if (!canAccrueInterest(h)) {
return 0;
}
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestPosted ?? 0));
}
/** Outstanding "Interest Receivable" GL balance — interest already posted (booked as income)
* but not yet actually received in cash. Always 0 for an NPI holding, since nothing is ever
* posted as a receivable on one. */
export function interestReceivableBalance(h: InvestmentHolding): number {
if (!canAccrueInterest(h)) {
return 0;
}
return Math.max(0, (h.interestPosted ?? 0) - (h.interestReceived ?? 0));
}
/** For an NPI holding, interest keeps accruing notionally but is held in memo/suspense — never
* posted as an asset or recognised as income — until it is actually collected in cash. */
export function suspenseInterest(h: InvestmentHolding, asOnStr: string): number {
if (canAccrueInterest(h)) {
return 0;
}
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestReceived ?? 0));
}
export function formatDDMMYYYY(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
@@ -208,7 +302,9 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
status: 'ACTIVE',
interestPosted: 42000,
interestReceived: 36000
},
{
certNo: '15624',
@@ -268,7 +364,10 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
status: 'ACTIVE',
provisionStatus: 'DOUBTFUL',
interestPosted: 115500,
interestReceived: 50000
},
{
certNo: '15627',
@@ -311,7 +410,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-002',
status: 'ACTIVE'
status: 'ACTIVE',
interestPosted: 181500,
interestReceived: 150000
},
{
certNo: 'GSEC-2002',
@@ -351,7 +452,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-002',
status: 'ACTIVE'
status: 'ACTIVE',
interestPosted: 67900,
interestReceived: 67900
},
{
certNo: 'GSEC-2004',
@@ -514,7 +617,9 @@ export const NON_SLR_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN NON SLR SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-003',
status: 'ACTIVE'
status: 'ACTIVE',
interestPosted: 89400,
interestReceived: 60000
}
];
@@ -597,7 +702,10 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN CORPORATE BONDS',
crToAc: '310070',
brokerCode: 'BRK-006',
status: 'ACTIVE'
status: 'ACTIVE',
provisionStatus: 'SUB_STANDARD',
interestPosted: 66000,
interestReceived: 40000
},
{
certNo: 'BOND-4005',
@@ -12,20 +12,28 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT · INTEREST</div>
<h1 class="pg-title">Investment Interest</h1>
<p class="pg-sub">
FD and Govt./Bond interest calculation batch runs, plus the Government Bond Interest, FD Interest and FD
registers.
Interest Receivable, FD and Govt./Bond interest calculation-and-post batch runs, Interest Receive, plus the
Government Bond Interest, FD Interest and FD registers.
</p>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'receivable'"
(click)="setTab('receivable')"
>
Interest Receivable
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'fdCalc'"
(click)="setTab('fdCalc')"
>
01 FD Interest Calculation
01 FD Interest Calculation &amp; Post
</button>
<button
type="button"
@@ -33,7 +41,15 @@
[class.pg-btn--active]="activeTab === 'bondCalc'"
(click)="setTab('bondCalc')"
>
02 Govt./Bond Int. Calculation
02 Govt./Bond Int. Calculation &amp; Post
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'receive'"
(click)="setTab('receive')"
>
Interest Receive
</button>
<button
type="button"
@@ -61,10 +77,106 @@
</button>
</div>
<!-- INTEREST RECEIVABLE -->
@if (activeTab === 'receivable') {
<div class="pg-card">
<div class="pos-section-label">
Interest Receivable — Posted but Not Yet Collected (₹{{ totalReceivable | number: '1.2-2' }})
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Interest Posted</th>
<th class="num">Interest Received</th>
<th class="num col-in">Receivable Balance</th>
</tr>
</thead>
<tbody>
@for (h of receivableRows; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ posted(h) | number: '1.2-2' }}</td>
<td class="num">₹{{ received(h) | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ receivableBalance(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No outstanding interest receivable.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="6">Total Receivable</td>
<td class="num">₹{{ totalReceivable | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
@if (suspenseRows.length > 0) {
<div class="pg-card">
<div class="pos-section-label">
Non-Performing Investments — Interest Held in Suspense (₹{{ totalSuspense | number: '1.2-2' }})
</div>
<p class="pg-sub" style="padding: 0 16px">
These holdings are classified Non-Performing (Sub-Standard/Doubtful/Loss). Per RBI income-recognition norms,
interest is not posted as an accrual receivable — it accrues in memo only, and is recognised as income solely
on actual cash receipt via the Interest Receive tab.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Provision Required</th>
<th class="num col-out">Interest in Suspense</th>
</tr>
</thead>
<tbody>
@for (h of suspenseRows; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ provisionRequired(h) | number: '1.2-2' }}</td>
<td class="num col-out">₹{{ suspense(h) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total Suspense</td>
<td class="num">₹{{ totalSuspense | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
}
<!-- 01 FD INTEREST CALCULATION -->
@if (activeTab === 'fdCalc') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Interest Calculation</div>
<div class="pos-section-label">Investment - FD Interest Calculation &amp; Post</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="fdCalcDate" placeholder="DD-MM-YYYY"
@@ -87,7 +199,7 @@
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Process</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Calculate &amp; Post</button>
</div>
<table class="pg-table">
<thead>
@@ -125,13 +237,19 @@
</tr>
</tfoot>
</table>
@if (fdCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 12px 16px 16px">
{{ fdCalcExcludedNpi.length }} certificate(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (see Interest Receivable tab for suspense amount).
</p>
}
</div>
}
<!-- 02 GOVT./BOND INT. CALCULATION -->
@if (activeTab === 'bondCalc') {
<div class="pg-card">
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation</div>
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation &amp; Post</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="bondCalcDate" placeholder="DD-MM-YYYY"
@@ -164,7 +282,9 @@
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">Process</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">
Calculate &amp; Post
</button>
</div>
<table class="pg-table">
<thead>
@@ -204,6 +324,100 @@
</tr>
</tfoot>
</table>
@if (bondCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 12px 16px 16px">
{{ bondCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (see Interest Receivable tab for suspense amount).
</p>
}
</div>
}
<!-- INTEREST RECEIVE -->
@if (activeTab === 'receive') {
<div class="pg-card">
<div class="pos-section-label">Interest Receive — Record Actual Cash Receipt</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Outstanding</th>
<th class="num">Amount to Receive</th>
<th></th>
</tr>
</thead>
<tbody>
@for (h of receiveTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ outstandingFor(h) | number: '1.2-2' }}</td>
<td class="num">
<input
type="number"
[(ngModel)]="receiveDrafts[h.certNo]"
[max]="outstandingFor(h)"
style="width: 120px; text-align: right"
/>
</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="receiveInterest(h)">Receive</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No interest outstanding for collection.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Interest Received Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Particulars</th>
<th class="num col-in">Amount Received</th>
</tr>
</thead>
<tbody>
@for (t of receivedLog; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No interest received yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="4">Total Received</td>
<td class="num">₹{{ receivedLogTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
@@ -19,22 +19,42 @@ import {
InvestmentHolding,
InvestmentTxn,
InstrumentType,
InvestmentProvisionStatus,
INSTRUMENT_LABELS,
PROVISION_STATUS_LABELS,
accruedInterest,
daysToMaturity,
maturityAmount,
todayDDMMYYYY
todayDDMMYYYY,
isNpi,
canAccrueInterest,
interestToPost,
interestReceivableBalance,
suspenseInterest,
provisionRequiredInv
} from '../investment-data';
type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' | 'fdRegister';
type InterestTab =
| 'receivable'
| 'fdCalc'
| 'bondCalc'
| 'receive'
| 'bondIntRegister'
| 'fdIntRegister'
| 'fdRegister';
/**
* The legacy "Interest" top-menu for Investment — 5 screens:
* 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation (both batch "Process" runs),
* 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register (listings).
* The legacy "Interest" top-menu for Investment — plus the full accrual lifecycle: Interest
* Receivable, 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation, Interest Receive,
* 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register.
*
* Interest figures are derived from the same holdings used by the Entry screens and Reports,
* so a register here always agrees with what a Purchase/Interest entry recorded.
*
* Provisioning: a holding classified Non-Performing (Sub-Standard/Doubtful/Loss) can never have
* interest calculated/posted on accrual basis (canAccrueInterest() blocks it) — only actual cash
* receipts are recognised for it, straight through Interest Receive, matching RBI's income
* recognition norms for Non-Performing Investments.
*/
@Component({
selector: 'mifosx-investment-interest',
@@ -50,7 +70,7 @@ type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' |
]
})
export class InvestmentInterestComponent {
activeTab: InterestTab = 'fdCalc';
activeTab: InterestTab = 'receivable';
asOnDate = todayDDMMYYYY();
fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS];
@@ -63,6 +83,9 @@ export class InvestmentInterestComponent {
readonly scriptOptions = SCRIPT_MASTER;
/** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */
receiveDrafts: Record<string, number | null> = {};
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -103,18 +126,63 @@ export class InvestmentInterestComponent {
return daysToMaturity(h, this.asOnDate);
}
get allInterestHoldings(): InvestmentHolding[] {
return [
...this.fdHoldings,
...this.bondHoldings
].filter((h) => h.status === 'ACTIVE');
}
isNpiHolding(h: InvestmentHolding): boolean {
return isNpi(h);
}
provisionLabel(h: InvestmentHolding): string {
return PROVISION_STATUS_LABELS[h.provisionStatus ?? 'STANDARD'];
}
provisionBadgeClass(h: InvestmentHolding): string {
const status: InvestmentProvisionStatus = h.provisionStatus ?? 'STANDARD';
return `inv-badge inv-badge--${status.toLowerCase()}`;
}
posted(h: InvestmentHolding): number {
return h.interestPosted ?? 0;
}
received(h: InvestmentHolding): number {
return h.interestReceived ?? 0;
}
receivableBalance(h: InvestmentHolding): number {
return interestReceivableBalance(h);
}
suspense(h: InvestmentHolding): number {
return suspenseInterest(h, this.asOnDate);
}
provisionRequired(h: InvestmentHolding): number {
return provisionRequiredInv(h);
}
// -------------------- 01 FD Interest Calculation --------------------
get fdCalcTargets(): InvestmentHolding[] {
const base = this.fdHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.fdCalcAll) {
return this.fdHoldings.filter((h) => h.status === 'ACTIVE');
return base;
}
return this.fdHoldings.filter(
(h) => h.status === 'ACTIVE' && (!this.fdCalcCertNo || h.certNo === this.fdCalcCertNo)
);
return base.filter((h) => !this.fdCalcCertNo || h.certNo === this.fdCalcCertNo);
}
/** NPI FD certificates excluded from calculation/posting — interest recognised on cash basis
* only, via the Interest Receive tab. */
get fdCalcExcludedNpi(): InvestmentHolding[] {
return this.fdHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h));
}
fdInterest(h: InvestmentHolding): number {
return accruedInterest(h, this.fdCalcDate || this.asOnDate);
return interestToPost(h, this.fdCalcDate || this.asOnDate);
}
get fdCalcTotal(): number {
@@ -122,36 +190,39 @@ export class InvestmentInterestComponent {
}
processFdInterest(): void {
const targets = this.fdCalcTargets;
const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
if (targets.length === 0) {
this.toast('No FD holdings match the selected filters.');
this.toast('No FD holdings have interest due to be posted.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'INTEREST',
voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
txnDate: this.fdCalcDate || this.asOnDate,
amount: this.fdInterest(h),
particulars: `FD interest calculated - ${h.party}`
}));
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.fdInterest(h);
h.interestPosted = this.posted(h) + amt;
return {
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'INTEREST',
voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
txnDate: this.fdCalcDate || this.asOnDate,
amount: amt,
particulars: `FD interest posted (accrual) - ${h.party}`
};
});
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`FD interest processed for ${targets.length} certificate(s). Total ₹${this.fdCalcTotal.toLocaleString('en-IN')}.`
);
this.toast(`FD interest posted for ${targets.length} certificate(s). Total ₹${total.toLocaleString('en-IN')}.`);
}
// -------------------- 02 Govt./Bond Int. Calculation --------------------
get bondCalcTargets(): InvestmentHolding[] {
const base = this.bondHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.bondCalcAll) {
return this.bondHoldings.filter((h) => h.status === 'ACTIVE');
return base;
}
return this.bondHoldings.filter((h) => {
if (h.status !== 'ACTIVE') return false;
return base.filter((h) => {
if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false;
if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false;
if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false;
@@ -159,8 +230,14 @@ export class InvestmentInterestComponent {
});
}
/** NPI Govt/Bond holdings excluded from calculation/posting — interest recognised on cash
* basis only, via the Interest Receive tab. */
get bondCalcExcludedNpi(): InvestmentHolding[] {
return this.bondHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h));
}
bondInterest(h: InvestmentHolding): number {
return accruedInterest(h, this.bondCalcDate || this.asOnDate);
return interestToPost(h, this.bondCalcDate || this.asOnDate);
}
get bondCalcTotal(): number {
@@ -168,29 +245,106 @@ export class InvestmentInterestComponent {
}
processBondInterest(): void {
const targets = this.bondCalcTargets;
const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
if (targets.length === 0) {
this.toast('No Govt/Bond holdings match the selected filters.');
this.toast('No Govt/Bond holdings have interest due to be posted.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
txnDate: this.bondCalcDate || this.asOnDate,
amount: this.bondInterest(h),
particulars: `Coupon/interest calculated - ${h.scriptName}`
}));
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.bondInterest(h);
h.interestPosted = this.posted(h) + amt;
return {
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
txnDate: this.bondCalcDate || this.asOnDate,
amount: amt,
particulars: `Coupon/interest posted (accrual) - ${h.scriptName}`
};
});
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`Interest processed for ${targets.length} holding(s). Total ₹${this.bondCalcTotal.toLocaleString('en-IN')}.`
this.toast(`Interest posted for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`);
}
// -------------------- Interest Receivable (all interest-bearing types) --------------------
/** Standard holdings with a posted-but-uncollected balance — a genuine "Interest Receivable"
* GL asset. */
get receivableRows(): InvestmentHolding[] {
return this.allInterestHoldings.filter((h) => canAccrueInterest(h) && this.receivableBalance(h) > 0);
}
/** NPI holdings with interest still accruing in memo/suspense — not a GL asset, shown
* separately so the treasury desk can see what cash-basis income is outstanding. */
get suspenseRows(): InvestmentHolding[] {
return this.allInterestHoldings.filter((h) => isNpi(h) && this.suspense(h) > 0);
}
get totalReceivable(): number {
return this.receivableRows.reduce((s, h) => s + this.receivableBalance(h), 0);
}
get totalSuspense(): number {
return this.suspenseRows.reduce((s, h) => s + this.suspense(h), 0);
}
// -------------------- Interest Receive (cash receipt, all interest-bearing types) --------------------
/** Everything with money outstanding to collect — Standard holdings against their posted
* receivable, and NPI holdings against their notional suspense (cash-basis recognition). */
get receiveTargets(): InvestmentHolding[] {
return this.allInterestHoldings.filter((h) =>
canAccrueInterest(h) ? this.receivableBalance(h) > 0 : this.suspense(h) > 0
);
}
outstandingFor(h: InvestmentHolding): number {
return canAccrueInterest(h) ? this.receivableBalance(h) : this.suspense(h);
}
get receivedLog(): InvestmentTxn[] {
return this.txns.filter((t) => t.txnType === 'INTEREST_RECEIVED');
}
get receivedLogTotal(): number {
return this.receivedLog.reduce((s, t) => s + t.amount, 0);
}
receiveInterest(h: InvestmentHolding): void {
const draft = this.receiveDrafts[h.certNo];
if (!draft || draft <= 0) {
this.toast('Enter a valid amount to receive.');
return;
}
let amt = draft;
let particulars: string;
if (canAccrueInterest(h)) {
amt = Math.min(draft, this.receivableBalance(h));
h.interestReceived = this.received(h) + amt;
particulars = `Interest received against posted receivable - ${h.scriptName || h.party}`;
} else {
h.interestReceived = this.received(h) + amt;
particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`;
}
this.txns = [
{
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST_RECEIVED',
voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`,
txnDate: this.asOnDate,
amount: amt,
particulars
},
...this.txns
];
this.toast(`₹${amt.toLocaleString('en-IN')} interest received for ${h.certNo}.`);
delete this.receiveDrafts[h.certNo];
}
// -------------------- 03 Government Bond Int Register --------------------
get bondIntRegister(): InvestmentTxn[] {
return this.txns.filter(
+26
View File
@@ -128,6 +128,32 @@
color: #b91c1c;
border: 1px solid #fecaca;
}
// Investment-provisioning classification (Non-Performing Investment) — same severity ramp as
// the NPA module's stage badges, kept local since it colours a different status field here.
&--standard {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--sub_standard {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--doubtful {
background: #fdf2f8;
color: #9d174d;
border: 1px solid #fbcfe8;
}
&--loss {
background: #1f2937;
color: #f9fafb;
border: 1px solid #1f2937;
}
}
.inv-form-grid {
@@ -0,0 +1,703 @@
/**
* 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/.
*/
// Dummy data layer for the "Reconciliation" mega-menu — 16 screens across 5 groups (Core &
// Books / Bank & Clearing / Digital & Payments / Instruments & Members / Compliance &
// Financials). Every reconciliation type shares one generic two-sided model (Side A vs Side B,
// difference, match status) so the UI, KPIs and "Mark as Reconciled" action are all driven by
// the same pure functions below — no report can duplicate/contradict another's numbers.
//
// Branches are the SAME 8 used by the NPA module (Branch 1/Branch 2 + 6 named branches) so a
// real bank demo never shows two different branch lists in two different menus.
import { BRANCHES, BranchMaster } from '../npa/npa-data';
export { BRANCHES };
export type { BranchMaster };
export type ReconGroupId = 'core' | 'bank' | 'digital' | 'instruments' | 'compliance';
export type ReconScope = 'BRANCH' | 'HEAD_OFFICE';
export type ReconStatus = 'MATCHED' | 'UNMATCHED' | 'PENDING' | 'RESOLVED';
export const GROUP_LABELS: Record<ReconGroupId, string> = {
core: 'Core & Books',
bank: 'Bank & Clearing',
digital: 'Digital & Payments',
instruments: 'Instruments & Members',
compliance: 'Compliance & Financials'
};
export interface ReconTypeMeta {
id: string;
group: ReconGroupId;
label: string;
/**
* BRANCH — the underlying activity is booked at a specific branch (vouchers, cheques,
* customer digital transactions, share members, fixed assets), so Head Office sees a branch
* selector + consolidated view and a branch login is locked to its own branch — same pattern
* as the NPA module.
* HEAD_OFFICE — the activity has no branch dimension in real life (bank-wide P&L/GL control,
* treasury's own bank accounts, statutory tax filing, vendor procurement, inter-branch
* consolidation) — only Head Office can open these; a branch login sees a restricted notice.
*/
scope: ReconScope;
sideALabel: string;
sideBLabel: string;
referenceLabel: string;
/** Only set for the one genuinely three-sided report (Three-way Digital Reconciliation). */
extraLabel?: string;
}
export const RECON_TYPES: ReconTypeMeta[] = [
{
id: 'voucher',
group: 'core',
label: 'Voucher Reconciliation',
scope: 'BRANCH',
sideALabel: 'System Voucher Amount',
sideBLabel: 'Physical / Scanned Voucher Amount',
referenceLabel: 'Voucher No'
},
{
id: 'sundry-dr-cr',
group: 'core',
label: 'Sundry Dr / Cr Reconciliation',
scope: 'BRANCH',
sideALabel: 'GL Balance',
sideBLabel: 'Party Ledger Balance',
referenceLabel: 'Party Code'
},
{
id: 'pnl-impact',
group: 'core',
label: 'P&L Impact Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'P&L as per GL',
sideBLabel: 'P&L as per MIS Schedule',
referenceLabel: 'Income / Expense Head'
},
{
id: 'gl-reconciliation',
group: 'core',
label: 'GL Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Total Debit',
sideBLabel: 'Total Credit',
referenceLabel: 'GL Code'
},
{
id: 'fixed-assets',
group: 'core',
label: 'Fixed Assets Reconciliation',
scope: 'BRANCH',
sideALabel: 'GL Asset Value (WDV)',
sideBLabel: 'Physical Verification Value',
referenceLabel: 'Asset Tag No'
},
{
id: 'inter-company',
group: 'core',
label: 'Inter-Company Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'HO Books (Inter-Branch A/c)',
sideBLabel: 'Branch Books (HO A/c)',
referenceLabel: 'Branch'
},
{
id: 'bank-reco',
group: 'bank',
label: 'Bank Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Balance per Bank GL (Books)',
sideBLabel: 'Balance per Bank Statement',
referenceLabel: 'Bank Account'
},
{
id: 'api-bank-reco',
group: 'bank',
label: 'API Based Bank Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Core Banking Balance',
sideBLabel: 'API Feed Balance',
referenceLabel: 'Bank Account (API)'
},
{
id: 'neft-rtgs',
group: 'bank',
label: 'NEFT / RTGS Reconciliation',
scope: 'BRANCH',
sideALabel: 'Amount per CBS',
sideBLabel: 'Amount per RBI Settlement File',
referenceLabel: 'UTR No'
},
{
id: 'clearing-reco',
group: 'bank',
label: 'Clearing Reconciliation',
scope: 'BRANCH',
sideALabel: 'Cheque Amount per CBS',
sideBLabel: 'Amount per Clearing House (CTS)',
referenceLabel: 'Cheque No'
},
{
id: 'udir',
group: 'digital',
label: 'UDIR (IMPS / UPI / ATM)',
scope: 'BRANCH',
sideALabel: 'Disputed Amount (Customer Claim)',
sideBLabel: 'NPCI UDIR Resolution Amount',
referenceLabel: 'UDIR Ticket No'
},
{
id: 'three-way-digital',
group: 'digital',
label: 'Three-way Digital Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Core Banking Record',
sideBLabel: 'Settlement Bank Record',
extraLabel: 'Switch / NPCI Record',
referenceLabel: 'Transaction Ref (RRN)'
},
{
id: 'nach-reco',
group: 'digital',
label: 'NACH Reconciliation',
scope: 'BRANCH',
sideALabel: 'Mandate Amount per CBS',
sideBLabel: 'Amount per NPCI Response File',
referenceLabel: 'UMRN'
},
{
id: 'share-member',
group: 'instruments',
label: 'Share Member Reconciliation',
scope: 'BRANCH',
sideALabel: 'Share GL Balance',
sideBLabel: 'Member Share Ledger Total',
referenceLabel: 'Folio No'
},
{
id: 'invoice-payment',
group: 'compliance',
label: 'Invoice Payment',
scope: 'HEAD_OFFICE',
sideALabel: 'Invoice Amount (Vendor Bill)',
sideBLabel: 'Amount Paid per GL',
referenceLabel: 'Invoice No'
},
{
id: 'tax-reco',
group: 'compliance',
label: 'Tax Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Deducted / Collected per Books',
sideBLabel: 'Remitted to Government (Challan)',
referenceLabel: 'Challan No'
}
];
export const DEFAULT_RECON_ID = 'voucher';
export function reconMeta(id: string): ReconTypeMeta {
return RECON_TYPES.find((t) => t.id === id) || RECON_TYPES[0];
}
export interface ReconItem {
id: string;
reconTypeId: string;
branchCode: string;
branchName: string;
groupLabel: string;
reference: string;
description: string;
date: string;
sideAAmount: number;
sideBAmount: number;
extraAmount?: number;
status: ReconStatus;
remarks: string;
}
export function difference(item: ReconItem): number {
return +(item.sideAAmount - item.sideBAmount).toFixed(2);
}
export function isOpen(item: ReconItem): boolean {
return item.status === 'UNMATCHED' || item.status === 'PENDING';
}
// -------------------------------------------------------------------------
// Date helpers (same shape as npa-data.ts, kept local so this module has no
// runtime dependency beyond the shared BRANCHES list)
// -------------------------------------------------------------------------
function fmt(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
function addDays(d: Date, days: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + days);
return r;
}
export function todayDDMMYYYY(): string {
return fmt(new Date());
}
// -------------------------------------------------------------------------
// Seeded RNG — deterministic dataset across reloads (own seed, independent of npa-data.ts)
// -------------------------------------------------------------------------
function mulberry32(seed: number): () => number {
return function random(): number {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rng = mulberry32(20260213);
const pick = <T>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
const between = (min: number, max: number): number => min + rng() * (max - min);
const round = (n: number): number => Math.round(n);
const VENDOR_NAMES = [
'Om Stationery Suppliers',
'Shubham Printers',
'Shree Balaji AMC Services',
'Metro Security Services',
'Sai Computers & Networking',
'Deccan Furniture Works',
'Unity Housekeeping Services',
'Precision Fire & Safety',
'Konark Electricals',
'Vasant Stationery Mart'
];
const PARTY_NAMES = [
'Kiran Hardware',
'Vishal Electronics',
'Nilesh Electricals',
'Ramdas Plumbing Works',
'Anita Traders',
'Ganesh Enterprises',
'Om Textiles',
'Balaji Agro Traders',
'Sunil Auto Parts',
'Meera Fabricators'
];
const ASSET_NAMES = [
'Office Furniture Set',
'Desktop Computer (Branch Counter)',
'UPS 3 KVA',
'Currency Counting Machine',
'Air Conditioner 1.5 Ton',
'CCTV Camera System',
'Branch Generator 15 KVA',
'Server Rack Cabinet',
'Biometric Attendance Device',
'Two-Wheeler (Recovery Officer)'
];
const GL_HEADS = [
'Interest on Loans & Advances',
'Interest on Investments',
'Interest Paid on Deposits',
'Commission & Exchange',
'Locker Rent Income',
'Establishment Charges',
'Rent, Taxes & Lighting',
'Printing & Stationery',
'Depreciation on Fixed Assets',
'Bad Debts Written Off',
'Audit Fees',
'Sundry Income'
];
const BANK_ACCOUNTS = [
'SBI Current Account - 40012345678',
'HDFC Bank Current Account - 5020012345',
'ICICI Bank Current Account - 000305012345',
'Axis Bank Current Account - 91020012345',
'RBI Current Account (Banking Dept)'
];
interface GenOptions {
reconTypeId: string;
refPrefix: string;
refDigits: number;
descriptions: string[];
minAmt: number;
maxAmt: number;
unmatchedRate: number;
branchScoped: boolean;
countPerBranch?: number;
totalCount?: number;
today: Date;
}
function statusFor(unmatchedRate: number): ReconStatus {
const r = rng();
if (r < unmatchedRate * 0.6) {
return 'UNMATCHED';
}
if (r < unmatchedRate) {
return 'PENDING';
}
return 'MATCHED';
}
function amountsFor(status: ReconStatus, minAmt: number, maxAmt: number): { a: number; b: number } {
const a = round(between(minAmt, maxAmt));
if (status === 'MATCHED') {
return { a, b: a };
}
const gapPct = between(0.02, 0.35);
const sign = rng() < 0.5 ? 1 : -1;
const b = round(a * (1 - sign * gapPct));
return { a, b };
}
function generateGeneric(opts: GenOptions): ReconItem[] {
const items: ReconItem[] = [];
const meta = reconMeta(opts.reconTypeId);
const branchLoop = opts.branchScoped ? BRANCHES : [{ code: 'HO', name: 'Head Office' }];
const perBranch = opts.countPerBranch ?? opts.totalCount ?? 20;
branchLoop.forEach((branch, branchIdx) => {
const count = opts.branchScoped ? perBranch : (opts.totalCount ?? perBranch);
for (let i = 0; i < count; i++) {
const status = statusFor(opts.unmatchedRate);
const { a, b } = amountsFor(status, opts.minAmt, opts.maxAmt);
const daysAgo = round(between(1, 75));
const seq = String(branchIdx * 1000 + i + 1).padStart(opts.refDigits, '0');
items.push({
id: `${opts.reconTypeId}-${branch.code}-${i}`,
reconTypeId: opts.reconTypeId,
branchCode: branch.code,
branchName: branch.name,
groupLabel: GROUP_LABELS[meta.group],
reference: `${opts.refPrefix}${seq}`,
description: pick(opts.descriptions),
date: fmt(addDays(opts.today, -daysAgo)),
sideAAmount: a,
sideBAmount: status === 'MATCHED' ? a : b,
status,
remarks: status === 'MATCHED' ? '' : ''
});
}
});
return items;
}
function generateInterCompany(today: Date): ReconItem[] {
return BRANCHES.map((b): ReconItem => {
const status = statusFor(0.25);
const { a, b: bAmt } = amountsFor(status, 200000, 3500000);
return {
id: `inter-company-${b.code}`,
reconTypeId: 'inter-company',
branchCode: 'HO',
branchName: 'Head Office',
groupLabel: GROUP_LABELS.core,
reference: b.name,
description: `Inter-branch settlement account — ${b.name}`,
date: fmt(addDays(today, -round(between(1, 10)))),
sideAAmount: a,
sideBAmount: status === 'MATCHED' ? a : bAmt,
status,
remarks: ''
};
});
}
function generateBankReco(reconTypeId: string, today: Date, refPrefix: string): ReconItem[] {
const templates = [
'Cheque issued, not yet presented',
'Cheque deposited, not yet credited by bank',
'Bank charges debited, not yet booked in CBS',
'Interest credited by bank, not yet booked in CBS',
'Direct credit by remitter, not yet identified',
'Standing instruction debited by bank, pending booking',
'RTGS outward debited by bank, in-transit in CBS'
];
const items: ReconItem[] = [];
BANK_ACCOUNTS.forEach((acct, idx) => {
const lineCount = reconTypeId === 'api-bank-reco' ? 5 : 6;
for (let i = 0; i < lineCount; i++) {
const status = statusFor(0.55);
const { a, b } = amountsFor(status, 15000, 850000);
items.push({
id: `${reconTypeId}-${idx}-${i}`,
reconTypeId,
branchCode: 'HO',
branchName: 'Head Office',
groupLabel: GROUP_LABELS.bank,
reference: `${refPrefix}${String(idx + 1).padStart(2, '0')}${String(i + 1).padStart(3, '0')}`,
description: `${acct} — ${pick(templates)}`,
date: fmt(addDays(today, -round(between(1, 30)))),
sideAAmount: a,
sideBAmount: status === 'MATCHED' ? a : b,
status,
remarks: ''
});
}
});
return items;
}
function generateThreeWayDigital(today: Date): ReconItem[] {
const channels = [
'IMPS',
'UPI',
'NEFT',
'RTGS',
'ATM (NFS)'
];
const items: ReconItem[] = [];
for (let i = 0; i < 45; i++) {
const status = statusFor(0.12);
const core = round(between(500, 250000));
const switchAmt = status === 'MATCHED' ? core : round(core * (1 - between(0.02, 0.2)));
const settlement = status === 'MATCHED' ? core : round(core * (1 - between(0.02, 0.15)));
items.push({
id: `three-way-digital-${i}`,
reconTypeId: 'three-way-digital',
branchCode: 'HO',
branchName: 'Head Office',
groupLabel: GROUP_LABELS.digital,
reference: `RRN${String(700000 + i)}`,
description: `${pick(channels)} transaction — Core vs Switch vs Settlement Bank`,
date: fmt(addDays(today, -round(between(1, 20)))),
sideAAmount: core,
sideBAmount: settlement,
extraAmount: switchAmt,
status,
remarks: ''
});
}
return items;
}
function generateAll(): ReconItem[] {
const today = new Date();
return [
...generateGeneric({
reconTypeId: 'voucher',
refPrefix: 'JV/26/',
refDigits: 5,
descriptions: [
'Cash deposit voucher',
'Cash withdrawal voucher',
'Journal entry — interest provision',
'Journal entry — expense booking',
'Transfer voucher — inter-GL',
'Debit voucher — charges recovery',
'Credit voucher — refund adjustment'
],
minAmt: 500,
maxAmt: 250000,
unmatchedRate: 0.08,
branchScoped: true,
countPerBranch: 15,
today
}),
...generateGeneric({
reconTypeId: 'sundry-dr-cr',
refPrefix: 'PTY-',
refDigits: 4,
descriptions: PARTY_NAMES,
minAmt: 5000,
maxAmt: 180000,
unmatchedRate: 0.18,
branchScoped: true,
countPerBranch: 10,
today
}),
...generateGeneric({
reconTypeId: 'pnl-impact',
refPrefix: 'GLH-',
refDigits: 3,
descriptions: GL_HEADS,
minAmt: 40000,
maxAmt: 4200000,
unmatchedRate: 0.15,
branchScoped: false,
totalCount: 24,
today
}),
...generateGeneric({
reconTypeId: 'gl-reconciliation',
refPrefix: 'GL-',
refDigits: 6,
descriptions: [
'Suspense account clearance check',
'Control account tally — deposits',
'Control account tally — advances',
'Inter-branch adjustment head',
'Sundry assets/liabilities head',
...GL_HEADS
],
minAmt: 20000,
maxAmt: 5200000,
unmatchedRate: 0.1,
branchScoped: false,
totalCount: 42,
today
}),
...generateGeneric({
reconTypeId: 'fixed-assets',
refPrefix: 'FA-',
refDigits: 4,
descriptions: ASSET_NAMES,
minAmt: 8000,
maxAmt: 650000,
unmatchedRate: 0.12,
branchScoped: true,
countPerBranch: 8,
today
}),
...generateInterCompany(today),
...generateBankReco('bank-reco', today, 'BRS-'),
...generateBankReco('api-bank-reco', today, 'API-'),
...generateGeneric({
reconTypeId: 'neft-rtgs',
refPrefix: 'UTR',
refDigits: 12,
descriptions: [
'RTGS outward — beneficiary bank settlement',
'RTGS inward — remitting bank settlement',
'NEFT outward — beneficiary bank settlement',
'NEFT inward — remitting bank settlement'
],
minAmt: 25000,
maxAmt: 1800000,
unmatchedRate: 0.06,
branchScoped: true,
countPerBranch: 12,
today
}),
...generateGeneric({
reconTypeId: 'clearing-reco',
refPrefix: 'CHQ-',
refDigits: 6,
descriptions: [
'Outward clearing — cheque sent',
'Inward clearing — cheque received',
'CTS grid settlement adjustment',
'Cheque return — insufficient funds'
],
minAmt: 10000,
maxAmt: 620000,
unmatchedRate: 0.09,
branchScoped: true,
countPerBranch: 10,
today
}),
...generateGeneric({
reconTypeId: 'udir',
refPrefix: 'UDIR-',
refDigits: 5,
descriptions: [
'IMPS failed but debited — customer dispute',
'UPI transaction disputed — merchant not credited',
'ATM cash-not-dispensed but account debited',
'UPI duplicate debit dispute'
],
minAmt: 500,
maxAmt: 45000,
unmatchedRate: 0.35,
branchScoped: true,
countPerBranch: 4,
today
}),
...generateThreeWayDigital(today),
...generateGeneric({
reconTypeId: 'nach-reco',
refPrefix: 'UMRN',
refDigits: 10,
descriptions: [
'NACH debit — loan EMI collection',
'NACH debit — gold loan EMI collection',
'NACH credit — salary/DBT disbursement',
'NACH mandate return — insufficient funds'
],
minAmt: 1500,
maxAmt: 85000,
unmatchedRate: 0.14,
branchScoped: true,
countPerBranch: 8,
today
}),
...generateGeneric({
reconTypeId: 'share-member',
refPrefix: 'FOL-',
refDigits: 5,
descriptions: [
'Regular member — share balance',
'Nominal member — share balance',
'Share transfer on account closure',
'Share application pending allotment'
],
minAmt: 500,
maxAmt: 25000,
unmatchedRate: 0.07,
branchScoped: true,
countPerBranch: 9,
today
}),
...generateGeneric({
reconTypeId: 'invoice-payment',
refPrefix: 'INV-',
refDigits: 5,
descriptions: VENDOR_NAMES,
minAmt: 3000,
maxAmt: 380000,
unmatchedRate: 0.13,
branchScoped: false,
totalCount: 30,
today
}),
...generateGeneric({
reconTypeId: 'tax-reco',
refPrefix: 'CHLN-',
refDigits: 4,
descriptions: [
'TDS on FD/RD interest payouts',
'TDS on salary payments',
'TDS on contractor/vendor payments',
'GST on service charges collected',
'GST on locker rent collected',
'TCS on cash transactions'
],
minAmt: 8000,
maxAmt: 620000,
unmatchedRate: 0.11,
branchScoped: false,
totalCount: 18,
today
})
];
}
export const RECON_ITEMS: ReconItem[] = generateAll();
// -------------------------------------------------------------------------
// KPI helpers — always computed from the item list handed in, so a filtered
// (branch-scoped) view and the full book can never disagree on a total.
// -------------------------------------------------------------------------
export function totalSideA(items: ReconItem[]): number {
return items.reduce((s, i) => s + i.sideAAmount, 0);
}
export function totalSideB(items: ReconItem[]): number {
return items.reduce((s, i) => s + i.sideBAmount, 0);
}
export function totalDifference(items: ReconItem[]): number {
return items.reduce((s, i) => s + Math.abs(difference(i)), 0);
}
export function countByStatus(items: ReconItem[], status: ReconStatus): number {
return items.filter((i) => i.status === status).length;
}
export function matchRate(items: ReconItem[]): number {
return items.length ? (countByStatus(items, 'MATCHED') / items.length) * 100 : 100;
}
@@ -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/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">
RECONCILIATION · {{ groupLabels[activeGroup] | uppercase }}
<span class="recon-scope-chip">{{ meta.scope === 'HEAD_OFFICE' ? 'Head Office Only' : 'Branch-wise' }}</span>
</div>
<h1 class="pg-title">{{ meta.label }}</h1>
<p class="pg-sub">
{{ meta.sideALabel }} vs {{ meta.sideBLabel }}
@if (meta.extraLabel) {
vs {{ meta.extraLabel }}
}
— as on {{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
@if (meta.scope === 'BRANCH') {
@if (isHeadOffice) {
<label class="pg-field">
<span>Branch</span>
<select class="pg-select" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
<option value="">-- All Branches --</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
} @else {
<label class="pg-field">
<span>Branch</span>
<span class="pg-chip">{{ scopeName }}</span>
</label>
}
} @else {
<label class="pg-field">
<span>Scope</span>
<span class="pg-chip">Head Office (Whole Bank)</span>
</label>
}
<label class="pg-field">
<span>As On</span>
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
</div>
</header>
@if (!isRestricted) {
<div class="inv-kpis">
<div class="inv-kpi">
<span class="inv-kpi__label">{{ meta.sideALabel }}</span>
<span class="inv-kpi__value">₹{{ totalA | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ items.length }} items</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">{{ meta.sideBLabel }}</span>
<span class="inv-kpi__value">₹{{ totalB | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ scopeName }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Absolute Difference</span>
<span class="inv-kpi__value" [class.npa-warn]="totalDiff > 0">₹{{ totalDiff | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ matchPct | number: '1.1-1' }}% matched</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Open Items</span>
<span class="inv-kpi__value npa-warn">{{ unmatchedCount + pendingCount }}</span>
<span class="inv-kpi__sub"
>{{ unmatchedCount }} unmatched · {{ pendingCount }} pending · {{ resolvedCount }} resolved</span
>
</div>
</div>
}
<div class="cctr-tabs">
@for (g of groups; track g) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === g"
(click)="goToReport(groupOf(g)[0].id)"
>
{{ groupLabels[g] }}
</button>
}
</div>
<div class="cctr-tabs">
@for (m of groupOf(activeGroup); track m.id) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === m.id"
(click)="goToReport(m.id)"
>
{{ m.label }}
</button>
}
</div>
@if (isRestricted) {
<div class="pg-card">
<div class="recon-restricted">
<fa-icon icon="university" class="recon-restricted__icon"></fa-icon>
<div class="recon-restricted__title">Managed Centrally by Head Office</div>
<p class="recon-restricted__body">
{{ meta.label }} has no branch-level dimension in real-world banking — it is reconciled centrally by the Head
Office Accounts/Treasury team. Your login ({{ currentOfficeName }}) does not have access to this screen.
Contact Head Office if you need a figure from this reconciliation.
</p>
</div>
</div>
} @else {
<div class="pg-card">
<div class="pos-section-label">{{ meta.label }} — {{ scopeName }}</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>{{ meta.referenceLabel }}</th>
<th>Description</th>
@if (meta.scope === 'BRANCH') {
<th>Branch</th>
}
<th>Date</th>
<th class="num">{{ meta.sideALabel }}</th>
<th class="num">{{ meta.sideBLabel }}</th>
@if (meta.extraLabel) {
<th class="num">{{ meta.extraLabel }}</th>
}
<th class="num">Difference</th>
<th>Status</th>
<th>Remarks / Action</th>
</tr>
</thead>
<tbody>
@for (item of paged(items); track item.id) {
<tr>
<td>
<span class="pg-chip">{{ item.reference }}</span>
</td>
<td>{{ item.description }}</td>
@if (meta.scope === 'BRANCH') {
<td>{{ item.branchName }}</td>
}
<td>{{ item.date }}</td>
<td class="num">₹{{ item.sideAAmount | number: '1.0-0' }}</td>
<td class="num">₹{{ item.sideBAmount | number: '1.0-0' }}</td>
@if (meta.extraLabel) {
<td class="num">₹{{ item.extraAmount | number: '1.0-0' }}</td>
}
<td class="num" [class.col-out]="diff(item) !== 0" [class.col-in]="diff(item) === 0">
₹{{ diff(item) | number: '1.0-0' }}
</td>
<td>
<span [class]="badgeClass(item.status)">{{ item.status }}</span>
</td>
<td>
@if (isItemOpen(item)) {
<div class="recon-action-row">
<input
type="text"
class="recon-remark-input"
[(ngModel)]="remarksDraft[item.id]"
placeholder="Remarks (optional)"
/>
<button type="button" class="pg-btn pg-btn--ghost" (click)="markReconciled(item)">
Mark as Reconciled
</button>
</div>
} @else if (item.status === 'RESOLVED') {
<span class="pg-sub">{{ item.remarks }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td
[attr.colspan]="meta.scope === 'BRANCH' ? (meta.extraLabel ? 10 : 9) : meta.extraLabel ? 9 : 8"
class="pg-empty"
>
No {{ meta.label }} items for this branch.
</td>
</tr>
}
</tbody>
</table>
</div>
@if (items.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(items) }}–{{ rangeEnd(items) }} of {{ items.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, items)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(items) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(items)"
(click)="goToPage(currentPage + 1, items)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,246 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
RECON_TYPES,
RECON_ITEMS,
BRANCHES,
GROUP_LABELS,
DEFAULT_RECON_ID,
ReconGroupId,
ReconTypeMeta,
ReconItem,
ReconStatus,
reconMeta,
difference,
isOpen,
totalSideA,
totalSideB,
totalDifference,
countByStatus,
matchRate,
todayDDMMYYYY
} from '../reconciliation-data';
/**
* The "Reconciliation" mega-menu — 16 screens across 5 groups (Core & Books / Bank & Clearing /
* Digital & Payments / Instruments & Members / Compliance & Financials) — collapsed into one
* route-param-driven component, mirroring the NPA module's pattern. Every screen shares the same
* generic two-sided (Side A vs Side B) reconciliation model in reconciliation-data.ts, and the
* same Head-Office-vs-branch access split used by NPA and Cash/Clearing/Transfer: activity that
* genuinely happens at a branch (vouchers, cheques, digital transactions, share members, fixed
* assets) is branch-scoped; activity with no branch dimension in real life (bank-wide P&L/GL
* control, treasury's own bank accounts, statutory tax filing, vendor procurement, inter-branch
* consolidation) is Head-Office-only.
*/
@Component({
selector: 'mifosx-reconciliation-report',
templateUrl: './reconciliation-report.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../../investment/investment-shared.scss',
'../../npa/npa-shared.scss',
'../reconciliation-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class ReconciliationReportComponent implements OnInit, OnDestroy {
readonly reportMeta = RECON_TYPES;
readonly groupLabels = GROUP_LABELS;
readonly branches = BRANCHES;
readonly groups: ReconGroupId[] = [
'core',
'bank',
'digital',
'instruments',
'compliance'
];
asOnDate = todayDDMMYYYY();
activeGroup: ReconGroupId = 'core';
activeReport = DEFAULT_RECON_ID;
isHeadOffice = false;
currentOfficeName = 'Branch Office';
private ownBranchCode: string | null = null;
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
branchFilter = '';
readonly pageSize = 20;
currentPage = 1;
remarksDraft: Record<string, string> = {};
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
private sub?: Subscription;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
ngOnInit(): void {
this.sub = this.route.paramMap.subscribe((params) => {
const id = params.get('reportId') || DEFAULT_RECON_ID;
const meta = this.reportMeta.find((m) => m.id === id) || this.reportMeta[0];
this.activeReport = meta.id;
this.activeGroup = meta.group;
this.currentPage = 1;
});
const creds = this.auth.getCredentials();
this.isHeadOffice = this.auth.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
if (this.isHeadOffice) {
this.branchFilter = '';
} else {
this.ownBranchCode =
this.branches.find((b) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
this.branchFilter = this.ownBranchCode || this.branches[0].code;
}
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
goToReport(reportId: string): void {
this.router.navigate([
'/reconciliation',
reportId
]);
}
groupOf(group: ReconGroupId): ReconTypeMeta[] {
return this.reportMeta.filter((m) => m.group === group);
}
setBranch(code: string): void {
this.branchFilter = code;
this.currentPage = 1;
}
get meta(): ReconTypeMeta {
return reconMeta(this.activeReport);
}
/** True when a branch-level login has opened a Head-Office-only reconciliation type. */
get isRestricted(): boolean {
return this.meta.scope === 'HEAD_OFFICE' && !this.isHeadOffice;
}
get scopeName(): string {
if (this.meta.scope === 'HEAD_OFFICE') {
return 'Head Office';
}
if (!this.isHeadOffice) {
return this.currentOfficeName;
}
return this.branchFilter ? this.branches.find((b) => b.code === this.branchFilter)?.name || '' : 'All Branches';
}
get items(): ReconItem[] {
const all = RECON_ITEMS.filter((i) => i.reconTypeId === this.activeReport);
if (this.meta.scope !== 'BRANCH' || !this.branchFilter) {
return all;
}
return all.filter((i) => i.branchCode === this.branchFilter);
}
diff(item: ReconItem): number {
return difference(item);
}
isItemOpen(item: ReconItem): boolean {
return isOpen(item);
}
badgeClass(status: ReconStatus): string {
return `recon-badge recon-badge--${status.toLowerCase()}`;
}
// -------------------- KPIs (always over the full `items` list, never the paged slice) --------------------
get totalA(): number {
return totalSideA(this.items);
}
get totalB(): number {
return totalSideB(this.items);
}
get totalDiff(): number {
return totalDifference(this.items);
}
get matchedCount(): number {
return countByStatus(this.items, 'MATCHED');
}
get unmatchedCount(): number {
return countByStatus(this.items, 'UNMATCHED');
}
get pendingCount(): number {
return countByStatus(this.items, 'PENDING');
}
get resolvedCount(): number {
return countByStatus(this.items, 'RESOLVED');
}
get matchPct(): number {
return matchRate(this.items);
}
// -------------------- Pagination --------------------
paged<T>(list: T[]): T[] {
const start = (this.currentPage - 1) * this.pageSize;
return list.slice(start, start + this.pageSize);
}
pageCount(list: unknown[]): number {
return Math.max(1, Math.ceil(list.length / this.pageSize));
}
rangeStart(list: unknown[]): number {
return list.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
}
rangeEnd(list: unknown[]): number {
return Math.min(this.currentPage * this.pageSize, list.length);
}
goToPage(page: number, list: unknown[]): void {
this.currentPage = Math.min(Math.max(1, page), this.pageCount(list));
}
// -------------------- Actions --------------------
markReconciled(item: ReconItem): void {
const remark = (this.remarksDraft[item.id] || '').trim();
item.status = 'RESOLVED';
item.remarks = remark || 'Reconciled — difference verified and squared off.';
delete this.remarksDraft[item.id];
this.toast(`${this.meta.referenceLabel} ${item.reference} marked as reconciled.`);
}
generate(): void {
this.currentPage = 1;
this.toast(`${this.meta.label} refreshed as on ${this.asOnDate}.`);
}
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,41 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { ReconciliationReportComponent } from './reconciliation-report/reconciliation-report.component';
import { DEFAULT_RECON_ID } from './reconciliation-data';
const routes: Routes = [
Route.withShell([
{
path: 'reconciliation',
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' },
children: [
{
path: '',
redirectTo: DEFAULT_RECON_ID,
pathMatch: 'full'
},
{
path: ':reportId',
component: ReconciliationReportComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ReconciliationRoutingModule {}
@@ -0,0 +1,107 @@
/**
* 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/.
*/
// Reconciliation-status colouring layered on top of ../cashier/cashier-shared.scss (pg-*,
// cctr-*), ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast) and
// ../npa/npa-shared.scss (pg-pagination) — reused as-is since they're generic primitives.
.recon-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
&--matched {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--unmatched {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--pending {
background: #fefce8;
color: #a16207;
border: 1px solid #fde68a;
}
&--resolved {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
}
.recon-scope-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
}
.recon-action-row {
display: flex;
align-items: center;
gap: 8px;
}
.recon-remark-input {
height: 32px;
min-width: 160px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
font-family: inherit;
color: #0d1b2a;
background: #fff;
}
.recon-restricted {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 56px 24px;
text-align: center;
color: #64748b;
&__icon {
font-size: 28px;
color: #94a3b8;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #334155;
}
&__body {
max-width: 440px;
font-size: 13px;
line-height: 1.5;
}
}
@@ -0,0 +1,22 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { ReconciliationRoutingModule } from './reconciliation-routing.module';
import { ReconciliationReportComponent } from './reconciliation-report/reconciliation-report.component';
@NgModule({
imports: [
SharedModule,
ReconciliationRoutingModule,
ReconciliationReportComponent
]
})
export class ReconciliationModule {}
+1 -1
View File
@@ -10,7 +10,7 @@
export default {
'mifos_x': {
'version': '260801',
'hash': 'bd753f49'
'hash': '1c125d97'
},
'allow_switching_backend_instance': true
};