feat: add investment reports, bond components, dashboard, and service implementation

This commit is contained in:
2026-10-23 16:49:50 +05:30
parent 731ab5a0e0
commit 8e98077158
6 changed files with 865 additions and 497 deletions
@@ -6,21 +6,23 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import {
ALL_HOLDINGS,
ALLOCATION_TARGET_IS_RBI_LINKED,
INSTRUMENT_LABELS,
InstrumentType,
InvestmentHolding,
TARGET_ALLOCATION_PCT,
accruedInterest,
daysToMaturity,
maturityAmount
} from '../investment-data';
import { forkJoin } from 'rxjs';
import { INSTRUMENT_LABELS, InstrumentType, parseDDMMYYYY, todayDDMMYYYY } from '../investment-data';
import { exportWorkbook } from '../investment-export';
import {
InvestmentBucketReportDto,
InvestmentBucketRowDto,
InvestmentFdDto,
InvestmentFdLimitDto,
InvestmentInterestReportDto,
InvestmentNonSlrLimitDto,
InvestmentPurchaseDto,
InvestmentService,
apiErrorMessage
} from '../investment.service';
interface InstrumentSummary {
type: InstrumentType;
@@ -28,25 +30,45 @@ interface InstrumentSummary {
route: string;
count: number;
faceValue: number;
bookValue: number;
accruedInterest: number;
maturityValue: number;
sharePct: number;
}
interface AllocationRow {
type: InstrumentType;
interface LimitRow {
label: string;
targetPct: number;
actualPct: number;
actualValue: number;
variancePct: number;
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated';
/** True when the target itself traces to an RBI SLR/Non-SLR rule rather than being a
* board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment in investment-data.ts. */
isRbiLinked: boolean;
basis: string;
limit: number | null;
used: number;
usedPct: number | null;
within: boolean;
}
/** 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. */
/** A holding maturing soon, or past maturity and not yet redeemed / closed. */
interface MaturingRow {
dealNo: string;
particulars: string;
instrument: string;
maturityDate: string;
days: number;
amount: number;
}
const ROUTE: Record<InstrumentType, string> = {
FD: '/investment/fd',
GOVT_SECURITY: '/investment/bonds',
NON_SLR: '/investment/bonds',
BOND: '/investment/bonds',
MUTUAL_FUND: '/investment/bonds',
SHARE: '/investment/share'
};
/**
* Cross-instrument portfolio dashboard on live data: what is held today by instrument (face value, book value,
* interest earned and not yet received, value at maturity), where the portfolio stands against the RBI limits, and
* what matures in the next 90 days.
*/
@Component({
selector: 'mifosx-investment-dashboard',
templateUrl: './investment-dashboard.component.html',
@@ -60,107 +82,216 @@ interface AllocationRow {
RouterLink
]
})
export class InvestmentDashboardComponent {
asOnDate = '';
export class InvestmentDashboardComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
asOnDate = todayDDMMYYYY();
loading = false;
error = '';
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
private readonly types: Array<{ type: InstrumentType; route: string }> = [
{ type: 'FD', route: '/investment/fd' },
{ type: 'GOVT_SECURITY', route: '/investment/bonds' },
{ type: 'NON_SLR', route: '/investment/bonds' },
{ type: 'BOND', route: '/investment/bonds' },
{ type: 'MUTUAL_FUND', route: '/investment/bonds' },
{ type: 'SHARE', route: '/investment/share' }
];
constructor() {
const d = new Date();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
summaries: InstrumentSummary[] = [];
limits: LimitRow[] = [];
limitNote = '';
maturingSoon: MaturingRow[] = [];
totalReceivable = 0;
totalProvisioned = 0;
ngOnInit(): void {
this.load();
}
private holdingsOf(type: InstrumentType): InvestmentHolding[] {
return ALL_HOLDINGS.filter((h) => h.instrumentType === type);
}
get summaries(): InstrumentSummary[] {
return this.types.map(({ type, route }) => {
const holdings = this.holdingsOf(type);
return {
type,
label: INSTRUMENT_LABELS[type],
route,
count: holdings.length,
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
accruedInterest: holdings.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0),
maturityValue: holdings.reduce((s, h) => s + maturityAmount(h), 0)
};
load(): void {
const today = this.asOnDate;
const d = parseDDMMYYYY(today);
const fyStart = `01-04-${d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1}`;
this.loading = true;
this.error = '';
forkJoin({
bucket: this.investmentService.getBucketReport(today),
interest: this.investmentService.getInterestReport(fyStart, today),
purchases: this.investmentService.getPurchases(),
fds: this.investmentService.getFds(),
fdQuote: this.investmentService.quoteFd({ amount: 0, intRate: 0, dealDate: today }),
nonSlr: this.investmentService.getNonSlrLimit(null, today, 0)
}).subscribe({
next: (r) => {
this.build(r.bucket, r.interest, r.purchases, r.fds, r.fdQuote.limit ?? null, r.nonSlr);
this.loading = false;
},
error: (err: unknown) => {
this.loading = false;
this.error = apiErrorMessage(err);
}
});
}
/** 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,
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
};
});
private build(
bucket: InvestmentBucketReportDto,
interest: InvestmentInterestReportDto,
purchases: InvestmentPurchaseDto[],
fds: InvestmentFdDto[],
fdLimit: InvestmentFdLimitDto | null,
nonSlr: InvestmentNonSlrLimitDto
): void {
const purchaseById = new Map<number, InvestmentPurchaseDto>(
purchases.map((p) => [
p.id,
p
])
);
const fdById = new Map<number, InvestmentFdDto>(
fds
.filter((f) => f.id != null)
.map((f) => [
f.id as number,
f
])
);
const receivable = new Map<string, number>(
interest.rows.map((x) => [
x.instrument + ':' + x.refId,
x.receivable
])
);
this.totalReceivable = interest.totalReceivable;
this.totalProvisioned = interest.totalProvisioned;
// Bucket rows are the Govt Securities, bonds and FDs held today; the purchase tells a Non-SLR from a Bond.
const typeOf = (r: InvestmentBucketRowDto): InstrumentType =>
r.instrument === 'FD' ? 'FD' : (purchaseById.get(r.refId)?.bondType ?? 'BOND');
const acc = new Map<InstrumentType, InstrumentSummary>();
const sum = (type: InstrumentType): InstrumentSummary => {
let s = acc.get(type);
if (!s) {
s = {
type,
label: INSTRUMENT_LABELS[type],
route: ROUTE[type],
count: 0,
faceValue: 0,
bookValue: 0,
accruedInterest: 0,
maturityValue: 0,
sharePct: 0
};
acc.set(type, s);
}
return s;
};
for (const r of bucket.rows) {
const s = sum(typeOf(r));
s.count += 1;
s.faceValue += r.faceValue;
s.bookValue += r.bookValue;
s.accruedInterest += receivable.get((r.instrument === 'FD' ? 'FD' : 'SECURITY') + ':' + r.refId) ?? 0;
// A security is redeemed at face value; an FD pays its maturity amount (just the principal on a payout FD).
s.maturityValue +=
r.instrument === 'FD' ? (fdById.get(r.refId)?.expectedMaturityAmount ?? r.faceValue) : r.faceValue;
}
// Mutual funds and shares have no maturity, so they are not in the buckets; their units are held at cost.
for (const p of purchases) {
if ((p.bondType === 'MUTUAL_FUND' || p.bondType === 'SHARE') && p.status === 'ACTIVE') {
const s = sum(p.bondType);
s.count += 1;
s.faceValue += p.faceValue;
s.bookValue += p.bookValue;
s.maturityValue += p.bookValue;
}
}
const order: InstrumentType[] = [
'GOVT_SECURITY',
'FD',
'NON_SLR',
'BOND',
'MUTUAL_FUND',
'SHARE'
];
const total = [...acc.values()].reduce((t, s) => t + s.bookValue, 0);
this.summaries = order
.map((t) => sum(t))
.map((s) => ({ ...s, sharePct: total > 0 ? (s.bookValue / total) * 100 : 0 }));
this.limits = [];
this.limitNote = '';
if (fdLimit?.grossLimit != null) {
const used = fdLimit.fdOutstanding + fdLimit.bankBalances;
this.limits.push(
this.limitRow(
'Deposits with other banks',
'FDs + current accounts, ≤ 20% of deposits',
fdLimit.grossLimit,
used
)
);
}
if (nonSlr.nonSlrLimit != null) {
this.limits.push(
this.limitRow(
'Non-SLR investments',
'Bonds, CP / CD, MF, ≤ 10% of deposits',
nonSlr.nonSlrLimit,
nonSlr.nonSlrOutstanding
)
);
this.limits.push(
this.limitRow(
'Unlisted non-SLR',
`≤ 10% of non-SLR held on ${nonSlr.asOnDate}`,
nonSlr.unlistedLimit,
nonSlr.unlistedOutstanding
)
);
} else {
this.limitNote = `Total deposits as on ${nonSlr.asOnDate} are not entered yet; enter them on the FD screen to see the RBI limits.`;
}
this.maturingSoon = bucket.rows
.filter((r) => r.daysToMaturity <= 90)
.map((r) => ({
dealNo: r.dealNo,
particulars: r.particulars,
instrument: INSTRUMENT_LABELS[typeOf(r)],
maturityDate: r.maturityDate,
days: r.daysToMaturity,
amount: r.instrument === 'FD' ? (fdById.get(r.refId)?.expectedMaturityAmount ?? r.faceValue) : r.faceValue
}));
}
private limitRow(label: string, basis: string, limit: number, used: number): LimitRow {
return { label, basis, limit, used, usedPct: limit > 0 ? (used / limit) * 100 : null, within: used <= limit };
}
get totalHoldings(): number {
return ALL_HOLDINGS.length;
return this.summaries.reduce((t, s) => t + s.count, 0);
}
get totalFaceValue(): number {
return ALL_HOLDINGS.reduce((s, h) => s + h.faceValue, 0);
return this.summaries.reduce((t, s) => t + s.faceValue, 0);
}
get totalBookValue(): number {
return this.summaries.reduce((t, s) => t + s.bookValue, 0);
}
get totalAccruedInterest(): number {
return ALL_HOLDINGS.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0);
return this.summaries.reduce((t, s) => t + s.accruedInterest, 0);
}
get totalMaturityValue(): number {
return ALL_HOLDINGS.reduce((s, h) => s + maturityAmount(h), 0);
return this.summaries.reduce((t, s) => t + s.maturityValue, 0);
}
get activeCount(): number {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE').length;
get overdueCount(): number {
return this.maturingSoon.filter((m) => m.days < 0).length;
}
get maturedCount(): number {
return ALL_HOLDINGS.filter((h) => h.status === 'MATURED').length;
}
/** Holdings maturing within the next 90 days — a "due-date diary" style widget. */
get maturingSoon(): InvestmentHolding[] {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && daysToMaturity(h, this.asOnDate) <= 90).sort(
(a, b) => daysToMaturity(a, this.asOnDate) - daysToMaturity(b, this.asOnDate)
);
}
daysLeft(h: InvestmentHolding): number {
return daysToMaturity(h, this.asOnDate);
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
/** Holdings of one or more instrument types, for the Quick Entry cards. */
countOf(...types: InstrumentType[]): number {
return this.summaries.filter((s) => types.includes(s.type)).reduce((t, s) => t + s.count, 0);
}
async exportExcel(): Promise<void> {
@@ -176,54 +307,97 @@ export class InvestmentDashboardComponent {
'Instrument Type',
'Holdings',
'Face Value',
'Accrued Interest',
'Book Value',
'Share %',
'Interest Receivable',
'Maturity Value'
],
widths: [
26,
12,
22,
10,
18,
18,
10,
18,
18
],
rows: this.summaries.map((s) => [
s.label,
s.count,
s.faceValue,
s.accruedInterest,
s.maturityValue
])
rows: [
...this.summaries.map((s) => [
s.label,
s.count,
s.faceValue,
s.bookValue,
Number(s.sharePct.toFixed(2)),
s.accruedInterest,
s.maturityValue
]),
[
'Total',
this.totalHoldings,
this.totalFaceValue,
this.totalBookValue,
100,
this.totalAccruedInterest,
this.totalMaturityValue
]
]
},
{
sheetName: 'Portfolio Allocation',
title: 'Portfolio Allocation — Target vs Actual',
sheetName: 'RBI Limits',
title: 'RBI Limits',
subtitle: `As on ${this.asOnDate}`,
headers: [
'Instrument Type',
'Target Basis',
'Target %',
'Actual %',
'Actual Value',
'Variance %',
'Limit',
'Basis',
'Allowed',
'Used',
'Used %',
'Status'
],
widths: [
26,
20,
12,
12,
40,
18,
18,
10,
14
],
rows: this.limits.map((l) => [
l.label,
l.basis,
l.limit,
l.used,
l.usedPct === null ? null : Number(l.usedPct.toFixed(2)),
l.within ? 'Within' : 'Above limit'
])
},
{
sheetName: 'Maturing in 90 days',
title: 'Maturing Within 90 Days (and overdue)',
subtitle: `As on ${this.asOnDate}`,
headers: [
'Deal No',
'Investment',
'Particulars',
'Maturity',
'Days',
'Amount'
],
widths: [
16,
16,
40,
12,
10,
18
],
rows: this.allocationVsTarget.map((a) => [
a.label,
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy',
a.targetPct,
a.actualPct,
a.actualValue,
a.variancePct,
a.statusLabel
rows: this.maturingSoon.map((m) => [
m.dealNo,
m.instrument,
m.particulars,
m.maturityDate,
m.days,
m.amount
])
}
],