Files
cbsnexora/src/app/investment/investment-dashboard/investment-dashboard.component.ts
T

430 lines
12 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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
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,
InvestmentSaleDto,
InvestmentService,
apiErrorMessage,
bookValueSoldByPurchase
} from '../investment.service';
interface InstrumentSummary {
type: InstrumentType;
label: string;
route: string;
count: number;
faceValue: number;
bookValue: number;
accruedInterest: number;
maturityValue: number;
sharePct: number;
}
interface LimitRow {
label: string;
basis: string;
limit: number | null;
used: number;
usedPct: number | null;
within: boolean;
}
/** 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',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
RouterLink
]
})
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;
summaries: InstrumentSummary[] = [];
limits: LimitRow[] = [];
limitNote = '';
maturingSoon: MaturingRow[] = [];
totalReceivable = 0;
totalProvisioned = 0;
ngOnInit(): void {
this.load();
}
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(),
sales: this.investmentService.getSales(),
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.sales, r.fds, r.fdQuote.limit ?? null, r.nonSlr);
this.loading = false;
},
error: (err: unknown) => {
this.loading = false;
this.error = apiErrorMessage(err);
}
});
}
private build(
bucket: InvestmentBucketReportDto,
interest: InvestmentInterestReportDto,
purchases: InvestmentPurchaseDto[],
sales: InvestmentSaleDto[],
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; the units still held are at cost.
const sold = bookValueSoldByPurchase(sales);
for (const p of purchases) {
if ((p.bondType === 'MUTUAL_FUND' || p.bondType === 'SHARE') && p.status === 'ACTIVE') {
const s = sum(p.bondType);
const book = p.bookValue - (sold.get(p.id) ?? 0);
s.count += 1;
s.faceValue += p.units ? (p.faceValue * p.balanceUnits) / p.units : p.faceValue;
s.bookValue += book;
s.maturityValue += book;
}
}
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 this.summaries.reduce((t, s) => t + s.count, 0);
}
get totalFaceValue(): number {
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 this.summaries.reduce((t, s) => t + s.accruedInterest, 0);
}
get totalMaturityValue(): number {
return this.summaries.reduce((t, s) => t + s.maturityValue, 0);
}
get overdueCount(): number {
return this.maturingSoon.filter((m) => m.days < 0).length;
}
/** 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> {
this.generating = true;
try {
await exportWorkbook(
[
{
sheetName: 'Instrument Breakup',
title: 'Investment Portfolio — Instrument-wise Break-up',
subtitle: `As on ${this.asOnDate}`,
headers: [
'Instrument Type',
'Holdings',
'Face Value',
'Book Value',
'Share %',
'Interest Receivable',
'Maturity Value'
],
widths: [
22,
10,
18,
18,
10,
18,
18
],
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: 'RBI Limits',
title: 'RBI Limits',
subtitle: `As on ${this.asOnDate}`,
headers: [
'Limit',
'Basis',
'Allowed',
'Used',
'Used %',
'Status'
],
widths: [
26,
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.maturingSoon.map((m) => [
m.dealNo,
m.instrument,
m.particulars,
m.maturityDate,
m.days,
m.amount
])
}
],
`Investment-Dashboard-${this.asOnDate}`
);
this.toast('Investment Dashboard exported.');
} catch (err) {
console.error('Investment Dashboard export failed', err);
this.toast('Could not generate the workbook — please retry.');
} finally {
this.generating = false;
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}