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.
This commit is contained in:
2026-08-01 17:56:44 +05:30
parent 1c125d97c5
commit 8ac4010967
8 changed files with 648 additions and 59 deletions
+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',