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.
402 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|