430 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|