Compare commits
3
Commits
2a9df828ca
...
8ac4010967
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac4010967 | ||
|
|
1c125d97c5 | ||
|
|
c935e2a62a |
+2
-2
@@ -69,8 +69,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "13mb",
|
||||
"maximumError": "14mb"
|
||||
"maximumWarning": "15mb",
|
||||
"maximumError": "17mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1306,27 +1306,27 @@
|
||||
<!-- Core / Books -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Core & 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&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 & 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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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 & 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(
|
||||
|
||||
@@ -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 {}
|
||||
@@ -10,7 +10,7 @@
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260801',
|
||||
'hash': 'bd753f49'
|
||||
'hash': '1c125d97'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user