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
+2 -2
View File
@@ -69,8 +69,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "13mb",
"maximumError": "14mb"
"maximumWarning": "15mb",
"maximumError": "17mb"
},
{
"type": "anyComponentStyle",
@@ -78,6 +78,58 @@
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div>
<table class="pg-table">
<thead>
<tr>
<th>Instrument Type</th>
<th class="num">Target %</th>
<th class="num">Actual %</th>
<th class="num">Actual Value</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of allocationVsTarget; track a.type) {
<tr>
<td>{{ a.label }}</td>
<td class="num">{{ a.targetPct }}%</td>
<td class="num">{{ a.actualPct }}%</td>
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
<td
class="num"
[class.col-out]="a.statusLabel !== 'Within Target'"
[class.col-in]="a.statusLabel === 'Within Target'"
>
{{ a.variancePct > 0 ? '+' : '' }}{{ a.variancePct }}%
</td>
<td>
<span
class="inv-badge"
[class.inv-badge--active]="a.statusLabel === 'Within Target'"
[class.inv-badge--sold]="a.statusLabel === 'Over-Allocated'"
[class.inv-badge--written_off]="a.statusLabel === 'Under-Allocated'"
>
{{ a.statusLabel }}
</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">100%</td>
<td class="num">100%</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
@if (maturingSoon.length === 0) {
@@ -14,6 +14,7 @@ import {
INSTRUMENT_LABELS,
InstrumentType,
InvestmentHolding,
TARGET_ALLOCATION_PCT,
accruedInterest,
daysToMaturity,
maturityAmount
@@ -29,6 +30,16 @@ interface InstrumentSummary {
maturityValue: number;
}
interface AllocationRow {
type: InstrumentType;
label: string;
targetPct: number;
actualPct: number;
actualValue: number;
variancePct: number;
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated';
}
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
@Component({
@@ -79,6 +90,30 @@ export class InvestmentDashboardComponent {
});
}
/** Board-approved policy target % per instrument type vs the portfolio's actual allocation
* (by invested/face value) — a ±2 percentage-point band is treated as "Within Target" since
* a treasury desk can't rebalance to the exact decimal every day. */
get allocationVsTarget(): AllocationRow[] {
const total = this.totalFaceValue;
return this.types.map(({ type }) => {
const value = this.holdingsOf(type).reduce((s, h) => s + h.faceValue, 0);
const actualPct = total ? (value / total) * 100 : 0;
const targetPct = TARGET_ALLOCATION_PCT[type];
const variancePct = +(actualPct - targetPct).toFixed(1);
const statusLabel: AllocationRow['statusLabel'] =
Math.abs(variancePct) <= 2 ? 'Within Target' : variancePct > 0 ? 'Over-Allocated' : 'Under-Allocated';
return {
type,
label: INSTRUMENT_LABELS[type],
targetPct,
actualPct: +actualPct.toFixed(1),
actualValue: value,
variancePct,
statusLabel
};
});
}
get totalHoldings(): number {
return ALL_HOLDINGS.length;
}
+114 -6
View File
@@ -22,6 +22,7 @@ export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' |
export type TxnType =
| 'PURCHASE'
| 'INTEREST'
| 'INTEREST_RECEIVED'
| 'DIVIDEND'
| 'SELL'
| 'REDEEM'
@@ -38,6 +39,32 @@ export const INSTRUMENT_LABELS: Record<InstrumentType, string> = {
SHARE: 'Share'
};
/** RBI investment-provisioning classification. A holding with no `provisionStatus` (or
* explicitly 'STANDARD') is performing normally. Once classified Sub-Standard/Doubtful/Loss —
* a Non-Performing Investment (NPI), the loan-book equivalent of an NPA — interest can no
* longer be recognised on accrual basis (see canAccrueInterest()) and a provision must be held
* against the investment (see provisionRequiredInv()). */
export type InvestmentProvisionStatus = 'STANDARD' | 'SUB_STANDARD' | 'DOUBTFUL' | 'LOSS';
export const PROVISION_STATUS_LABELS: Record<InvestmentProvisionStatus, string> = {
STANDARD: 'Standard',
SUB_STANDARD: 'Sub-Standard (NPI)',
DOUBTFUL: 'Doubtful (NPI)',
LOSS: 'Loss (NPI)'
};
/** Board-approved portfolio-policy target — % of the total investment corpus each instrument
* type should hold. Compared against actual holdings on the Dashboard so a treasury desk can
* see at a glance where the book has drifted from policy. */
export const TARGET_ALLOCATION_PCT: Record<InstrumentType, number> = {
FD: 15,
GOVT_SECURITY: 35,
NON_SLR: 10,
BOND: 20,
MUTUAL_FUND: 12,
SHARE: 8
};
export interface InvestmentHolding {
certNo: string;
instrumentType: InstrumentType;
@@ -57,6 +84,12 @@ export interface InvestmentHolding {
crToAc: string;
brokerCode: string;
status: HoldingStatus;
/** Absent/'STANDARD' for a healthy holding. Drives canAccrueInterest() and provisioning. */
provisionStatus?: InvestmentProvisionStatus;
/** Cumulative interest posted (booked as an accrual receivable/income entry) to date. */
interestPosted?: number;
/** Cumulative interest actually received in cash to date. */
interestReceived?: number;
}
export interface InvestmentTxn {
@@ -109,6 +142,67 @@ export function daysToMaturity(h: InvestmentHolding, asOnStr: string): number {
return Math.round((mat - asOn) / (24 * 3600 * 1000));
}
// -------------------- Provisioning & interest-lifecycle helpers --------------------
export function isNpi(h: InvestmentHolding): boolean {
return (h.provisionStatus ?? 'STANDARD') !== 'STANDARD';
}
export function provisionRatePctInv(status: InvestmentProvisionStatus): number {
switch (status) {
case 'STANDARD':
return 0;
case 'SUB_STANDARD':
return 25;
case 'DOUBTFUL':
return 50;
case 'LOSS':
return 100;
}
}
/** Provision required against the investment itself (principal), same idea as NPA provisioning
* on a loan book — computed on face value since that is this module's carrying-value figure. */
export function provisionRequiredInv(h: InvestmentHolding): number {
return Math.round(h.faceValue * (provisionRatePctInv(h.provisionStatus ?? 'STANDARD') / 100));
}
/** Interest can only be recognised on an accrual basis (posted as a receivable/income entry)
* while the investment is Standard. Once it is classified Non-Performing (Sub-Standard/
* Doubtful/Loss), RBI income-recognition norms require interest to move to cash basis — i.e.
* it is recognised only when actually received, never posted as an accrual receivable. */
export function canAccrueInterest(h: InvestmentHolding): boolean {
return !isNpi(h);
}
/** Interest still to be calculated & posted this cycle. Always 0 for an NPI holding — posting
* an accrual on a non-performing investment would overstate income. */
export function interestToPost(h: InvestmentHolding, asOnStr: string): number {
if (!canAccrueInterest(h)) {
return 0;
}
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestPosted ?? 0));
}
/** Outstanding "Interest Receivable" GL balance — interest already posted (booked as income)
* but not yet actually received in cash. Always 0 for an NPI holding, since nothing is ever
* posted as a receivable on one. */
export function interestReceivableBalance(h: InvestmentHolding): number {
if (!canAccrueInterest(h)) {
return 0;
}
return Math.max(0, (h.interestPosted ?? 0) - (h.interestReceived ?? 0));
}
/** For an NPI holding, interest keeps accruing notionally but is held in memo/suspense — never
* posted as an asset or recognised as income — until it is actually collected in cash. */
export function suspenseInterest(h: InvestmentHolding, asOnStr: string): number {
if (canAccrueInterest(h)) {
return 0;
}
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestReceived ?? 0));
}
export function formatDDMMYYYY(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
@@ -208,7 +302,9 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
status: 'ACTIVE',
interestPosted: 42000,
interestReceived: 36000
},
{
certNo: '15624',
@@ -268,7 +364,10 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
status: 'ACTIVE',
provisionStatus: 'DOUBTFUL',
interestPosted: 115500,
interestReceived: 50000
},
{
certNo: '15627',
@@ -311,7 +410,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-002',
status: 'ACTIVE'
status: 'ACTIVE',
interestPosted: 181500,
interestReceived: 150000
},
{
certNo: 'GSEC-2002',
@@ -351,7 +452,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-002',
status: 'ACTIVE'
status: 'ACTIVE',
interestPosted: 67900,
interestReceived: 67900
},
{
certNo: 'GSEC-2004',
@@ -514,7 +617,9 @@ export const NON_SLR_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN NON SLR SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-003',
status: 'ACTIVE'
status: 'ACTIVE',
interestPosted: 89400,
interestReceived: 60000
}
];
@@ -597,7 +702,10 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN CORPORATE BONDS',
crToAc: '310070',
brokerCode: 'BRK-006',
status: 'ACTIVE'
status: 'ACTIVE',
provisionStatus: 'SUB_STANDARD',
interestPosted: 66000,
interestReceived: 40000
},
{
certNo: 'BOND-4005',
@@ -12,20 +12,28 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT · INTEREST</div>
<h1 class="pg-title">Investment Interest</h1>
<p class="pg-sub">
FD and Govt./Bond interest calculation batch runs, plus the Government Bond Interest, FD Interest and FD
registers.
Interest Receivable, FD and Govt./Bond interest calculation-and-post batch runs, Interest Receive, plus the
Government Bond Interest, FD Interest and FD registers.
</p>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'receivable'"
(click)="setTab('receivable')"
>
Interest Receivable
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'fdCalc'"
(click)="setTab('fdCalc')"
>
01 FD Interest Calculation
01 FD Interest Calculation &amp; Post
</button>
<button
type="button"
@@ -33,7 +41,15 @@
[class.pg-btn--active]="activeTab === 'bondCalc'"
(click)="setTab('bondCalc')"
>
02 Govt./Bond Int. Calculation
02 Govt./Bond Int. Calculation &amp; Post
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'receive'"
(click)="setTab('receive')"
>
Interest Receive
</button>
<button
type="button"
@@ -61,10 +77,106 @@
</button>
</div>
<!-- INTEREST RECEIVABLE -->
@if (activeTab === 'receivable') {
<div class="pg-card">
<div class="pos-section-label">
Interest Receivable — Posted but Not Yet Collected (₹{{ totalReceivable | number: '1.2-2' }})
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Interest Posted</th>
<th class="num">Interest Received</th>
<th class="num col-in">Receivable Balance</th>
</tr>
</thead>
<tbody>
@for (h of receivableRows; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ posted(h) | number: '1.2-2' }}</td>
<td class="num">₹{{ received(h) | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ receivableBalance(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No outstanding interest receivable.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="6">Total Receivable</td>
<td class="num">₹{{ totalReceivable | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
@if (suspenseRows.length > 0) {
<div class="pg-card">
<div class="pos-section-label">
Non-Performing Investments — Interest Held in Suspense (₹{{ totalSuspense | number: '1.2-2' }})
</div>
<p class="pg-sub" style="padding: 0 16px">
These holdings are classified Non-Performing (Sub-Standard/Doubtful/Loss). Per RBI income-recognition norms,
interest is not posted as an accrual receivable — it accrues in memo only, and is recognised as income solely
on actual cash receipt via the Interest Receive tab.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Provision Required</th>
<th class="num col-out">Interest in Suspense</th>
</tr>
</thead>
<tbody>
@for (h of suspenseRows; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ provisionRequired(h) | number: '1.2-2' }}</td>
<td class="num col-out">₹{{ suspense(h) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total Suspense</td>
<td class="num">₹{{ totalSuspense | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
}
<!-- 01 FD INTEREST CALCULATION -->
@if (activeTab === 'fdCalc') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Interest Calculation</div>
<div class="pos-section-label">Investment - FD Interest Calculation &amp; Post</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="fdCalcDate" placeholder="DD-MM-YYYY"
@@ -87,7 +199,7 @@
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Process</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Calculate &amp; Post</button>
</div>
<table class="pg-table">
<thead>
@@ -125,13 +237,19 @@
</tr>
</tfoot>
</table>
@if (fdCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 12px 16px 16px">
{{ fdCalcExcludedNpi.length }} certificate(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (see Interest Receivable tab for suspense amount).
</p>
}
</div>
}
<!-- 02 GOVT./BOND INT. CALCULATION -->
@if (activeTab === 'bondCalc') {
<div class="pg-card">
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation</div>
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation &amp; Post</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="bondCalcDate" placeholder="DD-MM-YYYY"
@@ -164,7 +282,9 @@
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">Process</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">
Calculate &amp; Post
</button>
</div>
<table class="pg-table">
<thead>
@@ -204,6 +324,100 @@
</tr>
</tfoot>
</table>
@if (bondCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 12px 16px 16px">
{{ bondCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (see Interest Receivable tab for suspense amount).
</p>
}
</div>
}
<!-- INTEREST RECEIVE -->
@if (activeTab === 'receive') {
<div class="pg-card">
<div class="pos-section-label">Interest Receive — Record Actual Cash Receipt</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Outstanding</th>
<th class="num">Amount to Receive</th>
<th></th>
</tr>
</thead>
<tbody>
@for (h of receiveTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ outstandingFor(h) | number: '1.2-2' }}</td>
<td class="num">
<input
type="number"
[(ngModel)]="receiveDrafts[h.certNo]"
[max]="outstandingFor(h)"
style="width: 120px; text-align: right"
/>
</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="receiveInterest(h)">Receive</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No interest outstanding for collection.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Interest Received Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Particulars</th>
<th class="num col-in">Amount Received</th>
</tr>
</thead>
<tbody>
@for (t of receivedLog; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No interest received yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="4">Total Received</td>
<td class="num">₹{{ receivedLogTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
@@ -19,22 +19,42 @@ import {
InvestmentHolding,
InvestmentTxn,
InstrumentType,
InvestmentProvisionStatus,
INSTRUMENT_LABELS,
PROVISION_STATUS_LABELS,
accruedInterest,
daysToMaturity,
maturityAmount,
todayDDMMYYYY
todayDDMMYYYY,
isNpi,
canAccrueInterest,
interestToPost,
interestReceivableBalance,
suspenseInterest,
provisionRequiredInv
} from '../investment-data';
type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' | 'fdRegister';
type InterestTab =
| 'receivable'
| 'fdCalc'
| 'bondCalc'
| 'receive'
| 'bondIntRegister'
| 'fdIntRegister'
| 'fdRegister';
/**
* The legacy "Interest" top-menu for Investment — 5 screens:
* 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation (both batch "Process" runs),
* 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register (listings).
* The legacy "Interest" top-menu for Investment — plus the full accrual lifecycle: Interest
* Receivable, 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation, Interest Receive,
* 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register.
*
* Interest figures are derived from the same holdings used by the Entry screens and Reports,
* so a register here always agrees with what a Purchase/Interest entry recorded.
*
* Provisioning: a holding classified Non-Performing (Sub-Standard/Doubtful/Loss) can never have
* interest calculated/posted on accrual basis (canAccrueInterest() blocks it) — only actual cash
* receipts are recognised for it, straight through Interest Receive, matching RBI's income
* recognition norms for Non-Performing Investments.
*/
@Component({
selector: 'mifosx-investment-interest',
@@ -50,7 +70,7 @@ type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' |
]
})
export class InvestmentInterestComponent {
activeTab: InterestTab = 'fdCalc';
activeTab: InterestTab = 'receivable';
asOnDate = todayDDMMYYYY();
fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS];
@@ -63,6 +83,9 @@ export class InvestmentInterestComponent {
readonly scriptOptions = SCRIPT_MASTER;
/** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */
receiveDrafts: Record<string, number | null> = {};
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -103,18 +126,63 @@ export class InvestmentInterestComponent {
return daysToMaturity(h, this.asOnDate);
}
get allInterestHoldings(): InvestmentHolding[] {
return [
...this.fdHoldings,
...this.bondHoldings
].filter((h) => h.status === 'ACTIVE');
}
isNpiHolding(h: InvestmentHolding): boolean {
return isNpi(h);
}
provisionLabel(h: InvestmentHolding): string {
return PROVISION_STATUS_LABELS[h.provisionStatus ?? 'STANDARD'];
}
provisionBadgeClass(h: InvestmentHolding): string {
const status: InvestmentProvisionStatus = h.provisionStatus ?? 'STANDARD';
return `inv-badge inv-badge--${status.toLowerCase()}`;
}
posted(h: InvestmentHolding): number {
return h.interestPosted ?? 0;
}
received(h: InvestmentHolding): number {
return h.interestReceived ?? 0;
}
receivableBalance(h: InvestmentHolding): number {
return interestReceivableBalance(h);
}
suspense(h: InvestmentHolding): number {
return suspenseInterest(h, this.asOnDate);
}
provisionRequired(h: InvestmentHolding): number {
return provisionRequiredInv(h);
}
// -------------------- 01 FD Interest Calculation --------------------
get fdCalcTargets(): InvestmentHolding[] {
const base = this.fdHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.fdCalcAll) {
return this.fdHoldings.filter((h) => h.status === 'ACTIVE');
return base;
}
return this.fdHoldings.filter(
(h) => h.status === 'ACTIVE' && (!this.fdCalcCertNo || h.certNo === this.fdCalcCertNo)
);
return base.filter((h) => !this.fdCalcCertNo || h.certNo === this.fdCalcCertNo);
}
/** NPI FD certificates excluded from calculation/posting — interest recognised on cash basis
* only, via the Interest Receive tab. */
get fdCalcExcludedNpi(): InvestmentHolding[] {
return this.fdHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h));
}
fdInterest(h: InvestmentHolding): number {
return accruedInterest(h, this.fdCalcDate || this.asOnDate);
return interestToPost(h, this.fdCalcDate || this.asOnDate);
}
get fdCalcTotal(): number {
@@ -122,36 +190,39 @@ export class InvestmentInterestComponent {
}
processFdInterest(): void {
const targets = this.fdCalcTargets;
const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
if (targets.length === 0) {
this.toast('No FD holdings match the selected filters.');
this.toast('No FD holdings have interest due to be posted.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'INTEREST',
voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
txnDate: this.fdCalcDate || this.asOnDate,
amount: this.fdInterest(h),
particulars: `FD interest calculated - ${h.party}`
}));
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.fdInterest(h);
h.interestPosted = this.posted(h) + amt;
return {
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'INTEREST',
voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
txnDate: this.fdCalcDate || this.asOnDate,
amount: amt,
particulars: `FD interest posted (accrual) - ${h.party}`
};
});
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`FD interest processed for ${targets.length} certificate(s). Total ₹${this.fdCalcTotal.toLocaleString('en-IN')}.`
);
this.toast(`FD interest posted for ${targets.length} certificate(s). Total ₹${total.toLocaleString('en-IN')}.`);
}
// -------------------- 02 Govt./Bond Int. Calculation --------------------
get bondCalcTargets(): InvestmentHolding[] {
const base = this.bondHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.bondCalcAll) {
return this.bondHoldings.filter((h) => h.status === 'ACTIVE');
return base;
}
return this.bondHoldings.filter((h) => {
if (h.status !== 'ACTIVE') return false;
return base.filter((h) => {
if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false;
if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false;
if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false;
@@ -159,8 +230,14 @@ export class InvestmentInterestComponent {
});
}
/** NPI Govt/Bond holdings excluded from calculation/posting — interest recognised on cash
* basis only, via the Interest Receive tab. */
get bondCalcExcludedNpi(): InvestmentHolding[] {
return this.bondHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h));
}
bondInterest(h: InvestmentHolding): number {
return accruedInterest(h, this.bondCalcDate || this.asOnDate);
return interestToPost(h, this.bondCalcDate || this.asOnDate);
}
get bondCalcTotal(): number {
@@ -168,29 +245,106 @@ export class InvestmentInterestComponent {
}
processBondInterest(): void {
const targets = this.bondCalcTargets;
const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
if (targets.length === 0) {
this.toast('No Govt/Bond holdings match the selected filters.');
this.toast('No Govt/Bond holdings have interest due to be posted.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
txnDate: this.bondCalcDate || this.asOnDate,
amount: this.bondInterest(h),
particulars: `Coupon/interest calculated - ${h.scriptName}`
}));
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.bondInterest(h);
h.interestPosted = this.posted(h) + amt;
return {
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
txnDate: this.bondCalcDate || this.asOnDate,
amount: amt,
particulars: `Coupon/interest posted (accrual) - ${h.scriptName}`
};
});
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`Interest processed for ${targets.length} holding(s). Total ₹${this.bondCalcTotal.toLocaleString('en-IN')}.`
this.toast(`Interest posted for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`);
}
// -------------------- Interest Receivable (all interest-bearing types) --------------------
/** Standard holdings with a posted-but-uncollected balance — a genuine "Interest Receivable"
* GL asset. */
get receivableRows(): InvestmentHolding[] {
return this.allInterestHoldings.filter((h) => canAccrueInterest(h) && this.receivableBalance(h) > 0);
}
/** NPI holdings with interest still accruing in memo/suspense — not a GL asset, shown
* separately so the treasury desk can see what cash-basis income is outstanding. */
get suspenseRows(): InvestmentHolding[] {
return this.allInterestHoldings.filter((h) => isNpi(h) && this.suspense(h) > 0);
}
get totalReceivable(): number {
return this.receivableRows.reduce((s, h) => s + this.receivableBalance(h), 0);
}
get totalSuspense(): number {
return this.suspenseRows.reduce((s, h) => s + this.suspense(h), 0);
}
// -------------------- Interest Receive (cash receipt, all interest-bearing types) --------------------
/** Everything with money outstanding to collect — Standard holdings against their posted
* receivable, and NPI holdings against their notional suspense (cash-basis recognition). */
get receiveTargets(): InvestmentHolding[] {
return this.allInterestHoldings.filter((h) =>
canAccrueInterest(h) ? this.receivableBalance(h) > 0 : this.suspense(h) > 0
);
}
outstandingFor(h: InvestmentHolding): number {
return canAccrueInterest(h) ? this.receivableBalance(h) : this.suspense(h);
}
get receivedLog(): InvestmentTxn[] {
return this.txns.filter((t) => t.txnType === 'INTEREST_RECEIVED');
}
get receivedLogTotal(): number {
return this.receivedLog.reduce((s, t) => s + t.amount, 0);
}
receiveInterest(h: InvestmentHolding): void {
const draft = this.receiveDrafts[h.certNo];
if (!draft || draft <= 0) {
this.toast('Enter a valid amount to receive.');
return;
}
let amt = draft;
let particulars: string;
if (canAccrueInterest(h)) {
amt = Math.min(draft, this.receivableBalance(h));
h.interestReceived = this.received(h) + amt;
particulars = `Interest received against posted receivable - ${h.scriptName || h.party}`;
} else {
h.interestReceived = this.received(h) + amt;
particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`;
}
this.txns = [
{
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST_RECEIVED',
voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`,
txnDate: this.asOnDate,
amount: amt,
particulars
},
...this.txns
];
this.toast(`₹${amt.toLocaleString('en-IN')} interest received for ${h.certNo}.`);
delete this.receiveDrafts[h.certNo];
}
// -------------------- 03 Government Bond Int Register --------------------
get bondIntRegister(): InvestmentTxn[] {
return this.txns.filter(
+26
View File
@@ -128,6 +128,32 @@
color: #b91c1c;
border: 1px solid #fecaca;
}
// Investment-provisioning classification (Non-Performing Investment) — same severity ramp as
// the NPA module's stage badges, kept local since it colours a different status field here.
&--standard {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--sub_standard {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--doubtful {
background: #fdf2f8;
color: #9d174d;
border: 1px solid #fbcfe8;
}
&--loss {
background: #1f2937;
color: #f9fafb;
border: 1px solid #1f2937;
}
}
.inv-form-grid {
+1 -1
View File
@@ -10,7 +10,7 @@
export default {
'mifos_x': {
'version': '260801',
'hash': 'bd753f49'
'hash': '1c125d97'
},
'allow_switching_backend_instance': true
};