Files
cbsnexora/src/app/investment/investment-interest/investment-interest.component.ts
T
nitesh 8ac4010967 feat(investment): add portfolio allocation targets and interest receivable/post/receive lifecycle
Adds a Portfolio Allocation (Target vs Actual %) section to the Investment
Dashboard, and reworks Investment Interest into the full accrual lifecycle:
Interest Receivable, FD/Govt./Bond Interest Calculation & Post, and Interest
Receive, plus the existing registers.

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

Also bumps the production initial-bundle budget in angular.json (13/14mb ->
15/17mb) since the growing set of eagerly-loaded feature modules (Investment,
NPA, Reconciliation, GL Reports, OSS Returns) pushed the build past the old
limit.
2026-08-01 17:56:44 +05:30

402 lines
13 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
ALL_TXNS,
SCRIPT_MASTER,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
InvestmentProvisionStatus,
INSTRUMENT_LABELS,
PROVISION_STATUS_LABELS,
accruedInterest,
daysToMaturity,
maturityAmount,
todayDDMMYYYY,
isNpi,
canAccrueInterest,
interestToPost,
interestReceivableBalance,
suspenseInterest,
provisionRequiredInv
} from '../investment-data';
type InterestTab =
| 'receivable'
| 'fdCalc'
| 'bondCalc'
| 'receive'
| 'bondIntRegister'
| 'fdIntRegister'
| 'fdRegister';
/**
* 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',
templateUrl: './investment-interest.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class InvestmentInterestComponent {
activeTab: InterestTab = 'receivable';
asOnDate = todayDDMMYYYY();
fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS];
bondHoldings: InvestmentHolding[] = [
...GOVT_SECURITY_HOLDINGS,
...NON_SLR_HOLDINGS,
...BOND_HOLDINGS
];
txns: InvestmentTxn[] = [...ALL_TXNS];
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;
// 01 FD Interest Calculation
fdCalcDate = this.asOnDate;
fdCalcCertNo = '';
fdCalcAll = false;
// 02 Govt./Bond Int. Calculation
bondCalcDate = this.asOnDate;
bondCalcType: InstrumentType | '' = '';
bondCalcScript = '';
bondCalcDealNo = '';
bondCalcAll = false;
// Register filters
registerFromDate = '';
registerToDate = '';
setTab(tab: InterestTab): void {
this.activeTab = tab;
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
}
statusClass(h: InvestmentHolding): string {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
maturity(h: InvestmentHolding): number {
return maturityAmount(h);
}
daysLeft(h: InvestmentHolding): number {
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 base;
}
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 interestToPost(h, this.fdCalcDate || this.asOnDate);
}
get fdCalcTotal(): number {
return this.fdCalcTargets.reduce((s, h) => s + this.fdInterest(h), 0);
}
processFdInterest(): void {
const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
if (targets.length === 0) {
this.toast('No FD holdings have interest due to be posted.');
return;
}
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 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 base;
}
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;
return true;
});
}
/** 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 interestToPost(h, this.bondCalcDate || this.asOnDate);
}
get bondCalcTotal(): number {
return this.bondCalcTargets.reduce((s, h) => s + this.bondInterest(h), 0);
}
processBondInterest(): void {
const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
if (targets.length === 0) {
this.toast('No Govt/Bond holdings have interest due to be posted.');
return;
}
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 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(
(t) => t.txnType === 'INTEREST' && ([
'GOVT_SECURITY',
'NON_SLR',
'BOND'
] as InstrumentType[]).includes(t.instrumentType)
);
}
get bondIntRegisterTotal(): number {
return this.bondIntRegister.reduce((s, t) => s + t.amount, 0);
}
// -------------------- 04 FD Interest Register --------------------
get fdIntRegister(): InvestmentTxn[] {
return this.txns.filter((t) => t.txnType === 'INTEREST' && t.instrumentType === 'FD');
}
get fdIntRegisterTotal(): number {
return this.fdIntRegister.reduce((s, t) => s + t.amount, 0);
}
// -------------------- 05 FD Register --------------------
get fdRegister(): InvestmentHolding[] {
return this.fdHoldings;
}
get fdRegisterFaceTotal(): number {
return this.fdRegister.reduce((s, h) => s + h.faceValue, 0);
}
get fdRegisterMaturityTotal(): number {
return this.fdRegister.reduce((s, h) => s + this.maturity(h), 0);
}
accrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate);
}
get fdRegisterAccruedTotal(): number {
return this.fdRegister.reduce((s, h) => s + this.accrued(h), 0);
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}