/** * 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 = { 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 | 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( purchases.map((p) => [ p.id, p ]) ); const fdById = new Map( fds .filter((f) => f.id != null) .map((f) => [ f.id as number, f ]) ); const receivable = new Map( 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(); 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 { 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); } }