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
@@ -74,7 +74,11 @@ import {
InvestmentNonSlrLimitDto,
InvestmentService,
apiErrorMessage,
toHolding
toHolding,
toInterestTxn,
toPurchaseTxn,
toSaleTxn,
toTransferTxn
} from '../investment.service';
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
@@ -222,7 +226,7 @@ export class BondInvestmentComponent implements OnInit {
this.investmentService.getPurchases().subscribe({
next: (purchases) => {
this.holdings = purchases.map(toHolding);
this.txns = purchases.map((d) => this.purchaseTxn(d));
this.txns = purchases.map(toPurchaseTxn);
this.purchaseForm = this.blankPurchaseForm();
this.loadNonSlrLimit();
this.loadInterestLog();
@@ -253,115 +257,13 @@ export class BondInvestmentComponent implements OnInit {
);
this.txns = [
...others,
...postings.map((x) => this.interestTxn(x)),
...sales.map((x) => this.saleTxn(x)),
...transfers.map((x) => this.transferTxn(x))
...postings.map(toInterestTxn),
...sales.map(toSaleTxn),
...transfers.map(toTransferTxn)
].sort((a, b) => parseDDMMYYYY(b.txnDate).getTime() - parseDDMMYYYY(a.txnDate).getTime());
});
}
/** A security transfer shows the book value carried to the new security. */
private transferTxn(x: InvestmentSecurityTransferDto): InvestmentTxn {
return {
certNo: x.certNo,
instrumentType: x.bondType as InstrumentType,
txnType: 'TRANSFER',
voucherNo: x.voucherNo ?? `TRF/${x.dealNo}/${x.id}`,
txnDate: x.transferDate,
amount: x.bookValue,
particulars: `GOI category transfer - Deal ${x.dealNo}: ${x.fromScriptCode} ${x.fromScriptDesc} → ${x.toScriptCode} ${x.toScriptDesc}`,
glCode: x.toGlCode,
glName: x.glTransactionId
? `${x.toGlName} / Cr ${x.fromGlCode} (txn ${x.glTransactionId})`
: `${x.toGlName} (same GL, no entry)`
};
}
/** A sale or redemption shows the money into the bank; a pending sale is not in the GL yet. */
private saleTxn(x: InvestmentSaleDto): InvestmentTxn {
if (x.saleType === 'AMORTISE') {
return {
certNo: x.certNo,
instrumentType: x.bondType as InstrumentType,
txnType: 'AMORTIZE',
voucherNo: x.voucherNo ?? `AMZ/${x.dealNo}/${x.id}`,
txnDate: x.settlementDate,
amount: x.bookValueSold,
particulars: `${x.remarks ?? 'Premium amortisation'} - Deal ${x.dealNo} - ${x.scriptDesc} (${x.accruedDays} days)`,
glCode: '500604',
glName: `Amortisation of Premium / Cr Premium on Investments (txn ${x.glTransactionId})`
};
}
if (x.saleType === 'WRITEOFF') {
return {
certNo: x.certNo,
instrumentType: x.bondType as InstrumentType,
txnType: 'WRITE_OFF',
voucherNo: x.voucherNo ?? `WOF/${x.dealNo}/${x.id}`,
txnDate: x.settlementDate,
amount: x.bookValueSold,
particulars: `Written off - Deal ${x.dealNo} - ${x.scriptDesc}${x.remarks ? ' - ' + x.remarks : ''}`,
glCode: '500605',
glName: `Loss on Investments / Cr ${x.investmentGlCode} (txn ${x.glTransactionId})`
};
}
const redeem = x.saleType === 'REDEEM';
const pl =
x.profitLoss >= 0
? `profit ₹${x.profitLoss}`
: redeem
? `premium written off ₹${-x.profitLoss}`
: `loss ₹${-x.profitLoss}`;
return {
certNo: x.certNo,
instrumentType: x.bondType as InstrumentType,
txnType: x.saleType,
voucherNo: x.voucherNo ?? `SALE/${x.dealNo}/${x.id}`,
txnDate: x.settlementDate,
amount: x.netAmount,
particulars: redeem
? `Redeemed at maturity - Deal ${x.dealNo} - ${x.scriptDesc} (${pl})`
: `Sold ${x.units} units @ ${x.saleRate} to ${x.partyName ?? ''} - Deal ${x.dealNo} - ${x.scriptDesc} (${pl})`,
glCode: x.status === 'SETTLED' ? x.bankGlCode : '',
glName:
x.status === 'SETTLED'
? `${x.bankGlName} / Cr ${x.investmentGlCode} (txn ${x.glTransactionId})`
: 'Not posted (pending settlement)'
};
}
/** A coupon shows the cash received into the bank; a provision the interest booked as receivable. */
private interestTxn(x: InvestmentInterestPostingDto): InvestmentTxn {
const coupon = x.lineType === 'COUPON';
return {
certNo: x.certNo,
instrumentType: x.bondType as InstrumentType,
txnType: 'INTEREST',
voucherNo: x.voucherNo,
txnDate: x.intDate,
amount: coupon ? x.grossInterest : x.amount,
particulars: `${coupon ? 'Coupon' : 'Provision'} ${x.fromDate} to ${x.toDate} - Deal ${x.dealNo} - ${x.scriptDesc}`,
glCode: x.drGlCode,
glName: `${x.drGlName} / Cr ${x.incomeGlCode} (txn ${x.glTransactionId})`
};
}
/** A purchase reaches the GL only when it is settled, dated the settlement date. */
private purchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn {
const posted = !!d.glTransactionId;
return {
certNo: d.certNo,
instrumentType: d.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${d.dealNo}`,
txnDate: d.settlementDate,
amount: d.settlementAmount,
particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]`,
glCode: posted ? d.investmentGlCode : '',
glName: posted ? `${d.investmentGlName} (txn ${d.glTransactionId})` : 'Not posted (pending settlement)'
};
}
activeTab: BondTab = 'holdings';
/** Purchase Entry shows only the mandatory fields; the rest sit under "More Details". */
showMoreDetails = false;
@@ -12,34 +12,50 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT</div>
<h1 class="pg-title">Investment Portfolio Dashboard</h1>
<p class="pg-sub">
Consolidated position across FD, Govt Security, Non-SLR, Bonds, Mutual Fund and Share holdings as on
What the bank holds today across Govt Securities, FDs with banks, Non-SLR, Bonds, Mutual Funds and Shares, as on
{{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="loading" (click)="load()">
{{ loading ? 'Loading…' : 'Refresh' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating || loading" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
@if (error) {
<div class="pg-card">
<p class="inv-hint inv-hint--bad">{{ error }}</p>
</div>
}
<div class="inv-kpis">
<div class="inv-kpi">
<span class="inv-kpi__label">Total Holdings</span>
<span class="inv-kpi__label">Holdings</span>
<span class="inv-kpi__value">{{ totalHoldings }}</span>
<span class="inv-kpi__sub">{{ activeCount }} active · {{ maturedCount }} matured</span>
<span class="inv-kpi__sub"
>{{ maturingSoon.length }} due within 90 days{{ overdueCount ? ' (' + overdueCount + ' overdue)' : '' }}</span
>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Total Invested (Face Value)</span>
<span class="inv-kpi__value">₹{{ totalFaceValue | number: '1.2-2' }}</span>
<span class="inv-kpi__label">Total Invested (Book Value)</span>
<span class="inv-kpi__value">₹{{ totalBookValue | number: '1.2-2' }}</span>
<span class="inv-kpi__sub">Face value ₹{{ totalFaceValue | number: '1.2-2' }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Accrued Interest / Income</span>
<span class="inv-kpi__label">Interest Receivable</span>
<span class="inv-kpi__value col-in">₹{{ totalAccruedInterest | number: '1.2-2' }}</span>
<span class="inv-kpi__sub"
>Earned till today, not received · ₹{{ totalProvisioned | number: '1.2-2' }} provisioned</span
>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Projected Value at Maturity</span>
<span class="inv-kpi__label">Value at Maturity</span>
<span class="inv-kpi__value">₹{{ totalMaturityValue | number: '1.2-2' }}</span>
<span class="inv-kpi__sub">Securities at face value, FDs with interest</span>
</div>
</div>
@@ -51,7 +67,9 @@
<th>Instrument Type</th>
<th class="num">Holdings</th>
<th class="num">Face Value</th>
<th class="num col-in">Accrued Interest</th>
<th class="num">Book Value</th>
<th class="num">Share</th>
<th class="num col-in">Interest Receivable</th>
<th class="num col-net">Maturity Value</th>
<th></th>
</tr>
@@ -62,6 +80,8 @@
<td>{{ s.label }}</td>
<td class="num">{{ s.count }}</td>
<td class="num">₹{{ s.faceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ s.bookValue | number: '1.2-2' }}</td>
<td class="num">{{ s.sharePct | number: '1.1-1' }}%</td>
<td class="num col-in">₹{{ s.accruedInterest | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ s.maturityValue | number: '1.2-2' }}</td>
<td>
@@ -75,6 +95,8 @@
<td>Total</td>
<td class="num">{{ totalHoldings }}</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ totalBookValue | number: '1.2-2' }}</td>
<td class="num">{{ totalBookValue > 0 ? '100%' : '—' }}</td>
<td class="num">₹{{ totalAccruedInterest | number: '1.2-2' }}</td>
<td class="num">₹{{ totalMaturityValue | number: '1.2-2' }}</td>
<td></td>
@@ -84,93 +106,70 @@
</div>
<div class="pg-card">
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div>
<p class="pg-sub">
Govt Security, Non-SLR, Mutual Fund and Share targets trace to RBI's SLR floor (18% of NDTL) and Non-SLR ceiling
(≤10% of deposits) for Urban Co-operative Banks; Fixed Deposit and Bond are board-approved policy choices within
the room that framework leaves.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Instrument Type</th>
<th>Target Basis</th>
<th class="num">Target %</th>
<th class="num">Actual %</th>
<th class="num">Actual Value</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of allocationVsTarget; track a.type) {
<div class="pos-section-label">RBI Limits</div>
@if (limitNote) {
<p class="inv-hint inv-hint--bad">{{ limitNote }}</p>
}
@if (limits.length) {
<table class="pg-table">
<thead>
<tr>
<td>{{ a.label }}</td>
<td>
<span class="inv-badge" [class.inv-badge--matured]="a.isRbiLinked">
{{ a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy' }}
</span>
</td>
<td class="num">{{ a.targetPct }}%</td>
<td class="num">{{ a.actualPct }}%</td>
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
<td
class="num"
[class.col-out]="a.statusLabel !== 'Within Target'"
[class.col-in]="a.statusLabel === 'Within Target'"
>
{{ a.variancePct > 0 ? '+' : '' }}{{ a.variancePct }}%
</td>
<td>
<span
class="inv-badge"
[class.inv-badge--active]="a.statusLabel === 'Within Target'"
[class.inv-badge--sold]="a.statusLabel === 'Over-Allocated'"
[class.inv-badge--written_off]="a.statusLabel === 'Under-Allocated'"
>
{{ a.statusLabel }}
</span>
</td>
<th>Limit</th>
<th>Basis</th>
<th class="num">Allowed</th>
<th class="num">Used</th>
<th class="num">Used %</th>
<th>Status</th>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">100%</td>
<td class="num">100%</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</thead>
<tbody>
@for (l of limits; track l.label) {
<tr>
<td>{{ l.label }}</td>
<td>{{ l.basis }}</td>
<td class="num">₹{{ l.limit | number: '1.2-2' }}</td>
<td class="num">₹{{ l.used | number: '1.2-2' }}</td>
<td class="num">{{ l.usedPct === null ? '—' : (l.usedPct | number: '1.1-1') + '%' }}</td>
<td>
<span class="inv-badge" [class.inv-badge--active]="l.within" [class.inv-badge--written_off]="!l.within">
{{ l.within ? 'Within' : 'Above limit' }}
</span>
</td>
</tr>
}
</tbody>
</table>
}
</div>
<div class="pg-card">
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
<div class="pos-section-label">Maturing Within 90 Days</div>
@if (maturingSoon.length === 0) {
<div class="pg-empty">No holdings maturing in the next 90 days.</div>
} @else {
<div>
@for (h of maturingSoon; track h.certNo) {
@for (m of maturingSoon; track m.dealNo) {
<div class="inv-maturity-row">
<span>
<span class="pg-chip">{{ h.certNo }}</span>
{{ h.scriptName }} — {{ h.party }} ({{ instrumentLabel(h.instrumentType) }})
<span class="pg-chip">{{ m.dealNo }}</span>
{{ m.particulars }} ({{ m.instrument }})
</span>
<span>
Matures {{ h.maturityDate }} ·
Matures {{ m.maturityDate }} · ₹{{ m.amount | number: '1.2-2' }} ·
<span
class="inv-maturity-days"
[class.inv-maturity-days--soon]="daysLeft(h) <= 30"
[class.inv-maturity-days--ok]="daysLeft(h) > 30"
[class.inv-maturity-days--soon]="m.days <= 30"
[class.inv-maturity-days--ok]="m.days > 30"
>
{{ daysLeft(h) }} days
{{ m.days < 0 ? 'Overdue ' + -m.days + ' days' : m.days + ' days' }}
</span>
</span>
</div>
}
</div>
<div class="inv-form-actions" style="padding: 8px 16px">
<a class="pg-btn pg-btn--ghost" routerLink="/investment/diary">Open Due Date Diary</a>
</div>
}
</div>
@@ -179,19 +178,17 @@
<div class="inv-nav-grid" style="padding: 16px">
<a class="inv-nav-card" routerLink="/investment/fd">
<span class="inv-nav-card__label">Fixed Deposit</span>
<span class="inv-nav-card__count">{{ summaries[0].count }}</span>
<span class="inv-nav-card__value">Purchase / Interest / Sell / Write-off</span>
<span class="inv-nav-card__count">{{ countOf('FD') }}</span>
<span class="inv-nav-card__value">Purchase / Interest / Maturity / Renewal</span>
</a>
<a class="inv-nav-card" routerLink="/investment/bonds">
<span class="inv-nav-card__label">Govt Security / Non-SLR / Bonds / MF</span>
<span class="inv-nav-card__count">{{
summaries[1].count + summaries[2].count + summaries[3].count + summaries[4].count
}}</span>
<span class="inv-nav-card__count">{{ countOf('GOVT_SECURITY', 'NON_SLR', 'BOND', 'MUTUAL_FUND') }}</span>
<span class="inv-nav-card__value">Purchase / Interest / Sell / Amortize</span>
</a>
<a class="inv-nav-card" routerLink="/investment/share">
<span class="inv-nav-card__label">Share Entry</span>
<span class="inv-nav-card__count">{{ summaries[5].count }}</span>
<span class="inv-nav-card__count">{{ countOf('SHARE') }}</span>
<span class="inv-nav-card__value">Purchase / Dividend / Sell / Write-off</span>
</a>
<a class="inv-nav-card" routerLink="/investment/interest">
@@ -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
])
}
],
@@ -12,17 +12,26 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
<h1 class="pg-title">Investment Reports</h1>
<p class="pg-sub">
Balance reports, ledgers, registers, maturity lists and the consolidated portfolio — all derived live from the
same holdings/transactions used by the Entry screens.
Balance reports, ledgers, registers, maturity lists and the consolidated portfolio from the saved deals, FDs and
their GL entries, as on {{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="loading" (click)="load()">
{{ loading ? 'Loading…' : 'Refresh' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating || loading" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
@if (error) {
<div class="pg-card">
<p class="inv-hint inv-hint--bad">{{ error }}</p>
</div>
}
<div class="cctr-tabs">
<button
type="button"
@@ -124,7 +133,7 @@
: activeReport === 'shareBalance'
? shareHoldings
: fdHoldings;
track h.certNo
track h.instrumentType + ':' + h.id
) {
<tr>
<td>
@@ -138,6 +147,12 @@
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">
{{ activeReport === 'shareBalance' ? 'Share entry is not live yet.' : 'No holdings.' }}
</td>
</tr>
}
</tbody>
</table>
@@ -280,7 +295,7 @@
: activeReport === 'amortizeLedger'
? amortizeTxns
: dealSlips;
track t.voucherNo
track $index
) {
<tr>
<td>{{ t.txnDate }}</td>
@@ -303,7 +318,10 @@
}
@if (activeReport === 'intDueRegister') {
<div class="pg-card">
<div class="pos-section-label">Interest Due Register (Non-Cumulative Holdings)</div>
<div class="pos-section-label">
Interest Due Register — interest earned up to {{ asOnDate }} and not received yet ·
<a routerLink="/investment/diary">Due Date Diary</a>
</div>
<table class="pg-table">
<thead>
<tr>
@@ -311,11 +329,11 @@
<th>Script / Party</th>
<th>Frequency</th>
<th class="num">Rate</th>
<th class="num col-in">Due Amount (as on {{ asOnDate }})</th>
<th class="num col-in">Receivable (as on {{ asOnDate }})</th>
</tr>
</thead>
<tbody>
@for (h of interestDueRegister; track h.certNo) {
@for (h of interestDueRegister; track h.instrumentType + ':' + h.id) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
@@ -325,6 +343,10 @@
<td class="num">{{ h.interestRate }}%</td>
<td class="num col-in">₹{{ h.dueAmount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No interest receivable.</td>
</tr>
}
</tbody>
</table>
@@ -344,7 +366,7 @@
</tr>
</thead>
<tbody>
@for (h of slrBondRegister; track h.certNo) {
@for (h of slrBondRegister; track h.instrumentType + ':' + h.id) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
@@ -372,29 +394,33 @@
</tr>
</thead>
<tbody>
@for (t of receivedInterestTxns; track t.voucherNo) {
@for (t of receivedInterestTxns; track $index) {
<tr>
<td>{{ t.txnDate }}</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="pg-empty">No interest received yet.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Pending Interest (No Interest Posted Yet)</div>
<div class="pos-section-label">Pending Interest — earned up to {{ asOnDate }}, not received yet</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th class="num col-out">Accrued (Pending)</th>
<th class="num col-out">Receivable</th>
</tr>
</thead>
<tbody>
@for (h of pendingInterestHoldings; track h.certNo) {
@for (h of pendingInterestHoldings; track h.instrumentType + ':' + h.id) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
@@ -487,11 +513,11 @@
<th>Cert No</th>
<th>Party</th>
<th class="num">Face Value</th>
<th>Matured On</th>
<th>Maturity Date</th>
</tr>
</thead>
<tbody>
@for (h of maturedFdList; track h.certNo) {
@for (h of maturedFdList; track h.instrumentType + ':' + h.id) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
@@ -524,7 +550,7 @@
</tr>
</thead>
<tbody>
@for (h of maturingList; track h.certNo) {
@for (h of maturingList; track h.instrumentType + ':' + h.id) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
@@ -543,24 +569,12 @@
@if (activeReport === 'maturityPattern') {
<div class="pg-card">
<div class="pos-section-label">Maturity Pattern Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Bucket</th>
<th class="num">Holdings</th>
<th class="num">Face Value</th>
</tr>
</thead>
<tbody>
@for (b of maturityBuckets; track b.label) {
<tr>
<td>{{ b.label }}</td>
<td class="num">{{ b.count }}</td>
<td class="num">₹{{ b.faceValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<p class="pg-sub" style="padding: 0 16px">
The maturity pattern (up to 1 year, 1–3, 3–5 and over 5 years) is the Bucket Wise Investment report.
</p>
<div class="inv-form-actions" style="padding: 8px 16px">
<a class="pg-btn pg-btn--primary" routerLink="/investment/bucket-report">Open Bucket Wise Investment</a>
</div>
</div>
}
@if (
@@ -601,7 +615,7 @@
: activeReport === 'shareSecList'
? shareSecList
: mutualFundList;
track h.certNo
track h.instrumentType + ':' + h.id
) {
<tr>
<td>
@@ -615,6 +629,12 @@
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">
{{ activeReport === 'shareSecList' ? 'Share entry is not live yet.' : 'No holdings.' }}
</td>
</tr>
}
</tbody>
</table>
@@ -622,35 +642,35 @@
}
@if (activeReport === 'shiftingReport') {
<div class="pg-card">
<div class="pos-section-label">
Shifting Report — SLR Securities with Market Deviation ≥ 1% (AFS/HTM review candidates)
</div>
<div class="pos-section-label">Shifting Report — Govt Securities transferred to another script / category</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th class="num">Purchase Rate</th>
<th class="num">Market Price</th>
<th class="num">Deviation %</th>
<th>Date</th>
<th>Deal No</th>
<th>From</th>
<th>To</th>
<th class="num">Units</th>
<th class="num">Book Value</th>
<th>Voucher</th>
</tr>
</thead>
<tbody>
@for (h of shiftingCandidates; track h.certNo) {
@for (x of transfers; track x.id) {
<tr>
<td>{{ x.transferDate }}</td>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td class="num">{{ h.purchaseRate }}</td>
<td class="num">{{ h.marketPrice }}</td>
<td class="num" [class.col-in]="h.deviationPct > 0" [class.col-out]="h.deviationPct < 0">
{{ h.deviationPct }}%
<span class="pg-chip">{{ x.dealNo }}</span>
</td>
<td>{{ x.fromScriptCode }} {{ x.fromScriptDesc }}</td>
<td>{{ x.toScriptCode }} {{ x.toScriptDesc }}</td>
<td class="num">{{ x.units | number: '1.0-4' }}</td>
<td class="num">₹{{ x.bookValue | number: '1.2-2' }}</td>
<td>{{ x.voucherNo || '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No securities crossing the 1% deviation threshold today.</td>
<td colspan="7" class="pg-empty">No securities shifted.</td>
</tr>
}
</tbody>
@@ -687,7 +707,7 @@
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num"></td>
<td class="num">{{ portfolioTotalCount }}</td>
<td class="num">₹{{ portfolioTotalFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ portfolioTotalAccrued | number: '1.2-2' }}</td>
<td class="num">₹{{ portfolioTotalMaturity | number: '1.2-2' }}</td>
@@ -6,35 +6,44 @@
* 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 { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import {
ALL_HOLDINGS,
ALL_TXNS,
FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
MUTUAL_FUND_HOLDINGS,
SHARE_HOLDINGS,
SCRIPT_MARKET_DETAIL,
InstrumentType,
InvestmentHolding,
InvestmentTxn,
INSTRUMENT_LABELS,
accruedInterest,
maturityAmount,
daysToMaturity,
parseDDMMYYYY,
todayDDMMYYYY
} from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
import {
InvestmentDailyRateDto,
InvestmentFdDto,
InvestmentFdScheduleDto,
InvestmentSecurityTransferDto,
InvestmentService,
apiErrorMessage,
toFdHolding,
toFdTxns,
toHolding,
toInterestTxn,
toPurchaseTxn,
toSaleTxn,
toTransferTxn
} from '../investment.service';
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
/**
* ~20 legacy Investment reports, grouped into 4 tabs. Every figure here is derived from the
* same ALL_HOLDINGS/ALL_TXNS arrays used by the Entry screens and Dashboard — so a report can
* never drift out of sync with what a Purchase/Sell/Interest entry actually recorded.
* ~20 legacy Investment reports in 4 tabs, on live data: the saved purchases and FDs as holdings, and their GL
* entries (settlements, interest, sales, redemptions, amortisation, transfers, FD placements and closures) as the
* ledgers. Interest receivable comes from the Interest Received & Receivable report, market rates from the Daily
* Script Master.
*/
@Component({
selector: 'mifosx-investment-reports',
@@ -44,37 +53,176 @@ type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
'../investment-shared.scss'
],
standalone: true,
imports: [CommonModule]
imports: [
CommonModule,
RouterLink
]
})
export class InvestmentReportsComponent {
export class InvestmentReportsComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
activeGroup: ReportGroup = 'balance';
activeReport = 'govtSecBalance';
asOnDate = todayDDMMYYYY();
loading = false;
error = '';
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
/** Saved purchases and FDs as holdings, and their GL entries, newest first. */
holdings: InvestmentHolding[] = [];
txns: InvestmentTxn[] = [];
/** Coupons and FD interest paid out — interest actually received. */
receivedTxns: InvestmentTxn[] = [];
/** GOI category transfers (security shifted to another script / category). */
transfers: InvestmentSecurityTransferDto[] = [];
/** FD interest lines need one call per FD, so they load when a report needs them. */
fdInterestLoaded = false;
private fds: InvestmentFdDto[] = [];
private receivable = new Map<string, number>();
private fdMaturity = new Map<number, number>();
private latestRate = new Map<string, number>();
ngOnInit(): void {
this.load();
}
load(): void {
const d = parseDDMMYYYY(this.asOnDate);
const fyStart = `01-04-${d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1}`;
this.loading = true;
this.error = '';
this.fdInterestLoaded = false;
forkJoin({
purchases: this.investmentService.getPurchases(),
fds: this.investmentService.getFds(),
sales: this.investmentService.getSales(),
postings: this.investmentService.getInterestPostings(),
transfers: this.investmentService.getTransfers(),
interest: this.investmentService.getInterestReport(fyStart, this.asOnDate),
rates: this.investmentService.getDailyRates().pipe(catchError(() => of([] as InvestmentDailyRateDto[])))
}).subscribe({
next: (r) => {
this.fds = r.fds;
this.transfers = r.transfers;
this.holdings = [
...r.purchases.map(toHolding),
...r.fds.map(toFdHolding)
];
this.txns = this.byDateDesc([
...r.purchases.map(toPurchaseTxn),
...r.postings.map(toInterestTxn),
...r.sales.map(toSaleTxn),
...r.transfers.map(toTransferTxn),
...r.fds.flatMap((f) => toFdTxns(f, []))
]);
this.receivedTxns = this.byDateDesc(r.postings.filter((x) => x.lineType === 'COUPON').map(toInterestTxn));
this.receivable = new Map(
r.interest.rows.map((x) => [
x.instrument + ':' + x.refId,
x.receivable
])
);
this.fdMaturity = new Map(
r.fds
.filter((f) => f.id != null)
.map((f) => [
f.id as number,
f.expectedMaturityAmount ?? f.principal ?? 0
])
);
// Latest close on or before the as-on date, per script.
const asOn = parseDDMMYYYY(this.asOnDate).getTime();
this.latestRate = new Map();
[...r.rates]
.filter((x) => parseDDMMYYYY(x.rateDate).getTime() <= asOn)
.sort((a, b) => parseDDMMYYYY(a.rateDate).getTime() - parseDDMMYYYY(b.rateDate).getTime())
.forEach((x) => this.latestRate.set(x.scriptCode, x.closeRate));
this.loading = false;
if (this.activeReport === 'fdLedger' || this.activeReport === 'pendingReceived') {
this.loadFdInterest();
}
},
error: (err: unknown) => {
this.loading = false;
this.error = apiErrorMessage(err);
}
});
}
/** Adds each FD's posted interest lines to the ledgers (one schedule call per FD). */
private loadFdInterest(): void {
if (this.fdInterestLoaded || !this.fds.length) {
return;
}
this.fdInterestLoaded = true;
const withId = this.fds.filter((f) => f.id != null);
forkJoin(
withId.map((f) =>
this.investmentService
.getFdSchedule(f.id as number)
.pipe(catchError(() => of(null as InvestmentFdScheduleDto | null)))
)
).subscribe((schedules) => {
const lines: InvestmentTxn[] = [];
const received: InvestmentTxn[] = [];
schedules.forEach((s, i) => {
if (!s) {
return;
}
const interest = toFdTxns(withId[i], s.rows).filter((t) => t.txnType === 'INTEREST');
lines.push(...interest);
received.push(...interest.filter((t) => !t.particulars.startsWith('Provision')));
});
this.txns = this.byDateDesc([
...this.txns,
...lines
]);
this.receivedTxns = this.byDateDesc([
...this.receivedTxns,
...received
]);
});
}
private byDateDesc(txns: InvestmentTxn[]): InvestmentTxn[] {
return txns
.filter((t) => !!t.txnDate)
.sort((a, b) => parseDDMMYYYY(b.txnDate).getTime() - parseDDMMYYYY(a.txnDate).getTime());
}
setGroup(group: ReportGroup, defaultReport: string): void {
this.activeGroup = group;
this.activeReport = defaultReport;
this.setReport(defaultReport);
}
setReport(report: string): void {
this.activeReport = report;
if (report === 'fdLedger' || report === 'pendingReceived') {
this.loadFdInterest();
}
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
}
/** Interest earned up to the as-on date and not yet received (Interest Received & Receivable report). */
accrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate);
return this.receivable.get((h.instrumentType === 'FD' ? 'FD' : 'SECURITY') + ':' + h.id) ?? 0;
}
/** What comes back at maturity: the FD maturity amount, a security's face value, a fund's cost. */
maturity(h: InvestmentHolding): number {
return maturityAmount(h);
if (h.instrumentType === 'FD') {
return this.fdMaturity.get(h.id ?? -1) ?? h.faceValue;
}
return h.instrumentType === 'MUTUAL_FUND' || h.instrumentType === 'SHARE'
? (h.bookValue ?? h.faceValue)
: h.faceValue;
}
daysLeft(h: InvestmentHolding): number {
@@ -85,23 +233,32 @@ export class InvestmentReportsComponent {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
/** Latest close in the Daily Script Master on or before the as-on date. */
marketPrice(scriptCode: string): number | null {
return SCRIPT_MARKET_DETAIL.find((m) => m.scriptCode === scriptCode)?.marketPrice ?? null;
return this.latestRate.get(scriptCode) ?? null;
}
private active(type?: InstrumentType): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && (!type || h.instrumentType === type));
}
private ofType(type: InstrumentType): InvestmentHolding[] {
return this.holdings.filter((h) => h.instrumentType === type);
}
// -------------------- Balance Reports --------------------
get govtSecHoldings(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS;
return this.active('GOVT_SECURITY');
}
get shareHoldings(): InvestmentHolding[] {
return SHARE_HOLDINGS;
return this.active('SHARE');
}
get fdHoldings(): InvestmentHolding[] {
return FD_HOLDINGS;
return this.active('FD');
}
get bankInvestmentList(): Array<{ party: string; count: number; totalFaceValue: number }> {
const map = new Map<string, { party: string; count: number; totalFaceValue: number }>();
for (const h of ALL_HOLDINGS) {
for (const h of this.active()) {
const row = map.get(h.party) || { party: h.party, count: 0, totalFaceValue: 0 };
row.count += 1;
row.totalFaceValue += h.faceValue;
@@ -112,112 +269,68 @@ export class InvestmentReportsComponent {
// -------------------- Ledgers & Registers --------------------
get fdTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.instrumentType === 'FD');
return this.txns.filter((t) => t.instrumentType === 'FD');
}
get bondTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => ([
return this.txns.filter((t) => ([
'GOVT_SECURITY',
'NON_SLR',
'BOND'
'BOND',
'MUTUAL_FUND'
] as InstrumentType[]).includes(t.instrumentType));
}
get shareTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
return this.txns.filter((t) => t.instrumentType === 'SHARE');
}
get amortizeTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'AMORTIZE');
return this.txns.filter((t) => t.txnType === 'AMORTIZE');
}
get dealSlips(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'PURCHASE');
return this.txns.filter((t) => t.txnType === 'PURCHASE' && t.instrumentType !== 'FD');
}
get slrBondRegister(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS.filter((h) => h.subType === 'SLR');
return this.active('GOVT_SECURITY');
}
/** Holdings with interest earned up to the as-on date and not received yet, with the amount. */
get interestDueRegister(): Array<InvestmentHolding & { dueAmount: number }> {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE').map((h) => ({
...h,
dueAmount: this.accrued(h)
}));
return this.active()
.map((h) => ({ ...h, dueAmount: this.accrued(h) }))
.filter((h) => h.dueAmount > 0);
}
get receivedInterestTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'INTEREST' || t.txnType === 'DIVIDEND');
return this.receivedTxns;
}
get pendingInterestHoldings(): InvestmentHolding[] {
const receivedCerts = new Set(this.receivedInterestTxns.map((t) => t.certNo));
return ALL_HOLDINGS.filter(
(h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE' && !receivedCerts.has(h.certNo)
);
return this.active().filter((h) => this.accrued(h) > 0);
}
// -------------------- Maturity & Lists --------------------
get maturedFdList(): InvestmentHolding[] {
return FD_HOLDINGS.filter((h) => h.status === 'MATURED');
return this.ofType('FD').filter((h) => h.status === 'MATURED');
}
get maturingList(): InvestmentHolding[] {
return [
...FD_HOLDINGS,
...GOVT_SECURITY_HOLDINGS
]
.filter((h) => h.status === 'ACTIVE')
.sort((a, b) => this.daysLeft(a) - this.daysLeft(b));
}
get maturityBuckets(): Array<{ label: string; count: number; faceValue: number }> {
const buckets = [
{ label: '0-1 Year', min: -99999, max: 365, count: 0, faceValue: 0 },
{ label: '1-3 Years', min: 366, max: 1095, count: 0, faceValue: 0 },
{ label: '3-5 Years', min: 1096, max: 1825, count: 0, faceValue: 0 },
{ label: 'Above 5 Years', min: 1826, max: 999999, count: 0, faceValue: 0 }
];
for (const h of ALL_HOLDINGS.filter((x) => x.status === 'ACTIVE')) {
const days = this.daysLeft(h);
const bucket = buckets.find((b) => days >= b.min && days <= b.max);
if (bucket) {
bucket.count += 1;
bucket.faceValue += h.faceValue;
}
}
return buckets;
...this.active('FD'),
...this.active('GOVT_SECURITY')
].sort((a, b) => this.daysLeft(a) - this.daysLeft(b));
}
get govtSecList(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS;
return this.ofType('GOVT_SECURITY');
}
get fdSecList(): InvestmentHolding[] {
return FD_HOLDINGS;
return this.ofType('FD');
}
get shareSecList(): InvestmentHolding[] {
return SHARE_HOLDINGS;
return this.ofType('SHARE');
}
get mutualFundList(): InvestmentHolding[] {
return MUTUAL_FUND_HOLDINGS;
}
get nonSlrList(): InvestmentHolding[] {
return NON_SLR_HOLDINGS;
}
get bondList(): InvestmentHolding[] {
return BOND_HOLDINGS;
}
/** Shifting Report — SLR securities whose market value has moved away from book value by
* more than 1%, a proxy for "category shift" candidates (AFS/HTM reclassification review). */
get shiftingCandidates(): Array<InvestmentHolding & { marketPrice: number; deviationPct: number }> {
return GOVT_SECURITY_HOLDINGS.map((h) => {
const mp = this.marketPrice(h.scriptCode);
if (mp === null) {
return null;
}
const deviationPct = +(((mp - h.purchaseRate) / h.purchaseRate) * 100).toFixed(2);
return { ...h, marketPrice: mp, deviationPct };
})
.filter(
(x): x is InvestmentHolding & { marketPrice: number; deviationPct: number } =>
x !== null && Math.abs(x.deviationPct) >= 1
)
.sort((a, b) => Math.abs(b.deviationPct) - Math.abs(a.deviationPct));
return this.ofType('MUTUAL_FUND');
}
// -------------------- Portfolio --------------------
private readonly portfolioTypes: InstrumentType[] = [
'FD',
'GOVT_SECURITY',
'FD',
'NON_SLR',
'BOND',
'MUTUAL_FUND',
@@ -231,7 +344,7 @@ export class InvestmentReportsComponent {
maturityValue: number;
}> {
return this.portfolioTypes.map((type) => {
const holdings = ALL_HOLDINGS.filter((h) => h.instrumentType === type);
const holdings = this.active(type);
return {
label: INSTRUMENT_LABELS[type],
count: holdings.length,
@@ -241,6 +354,9 @@ export class InvestmentReportsComponent {
};
});
}
get portfolioTotalCount(): number {
return this.portfolioSummary.reduce((s, r) => s + r.count, 0);
}
get portfolioTotalFaceValue(): number {
return this.portfolioSummary.reduce((s, r) => s + r.faceValue, 0);
}
@@ -480,26 +596,6 @@ export class InvestmentReportsComponent {
this.holdingsExportWidths,
this.maturingList.map((h) => this.holdingsExportRow(h))
);
case 'maturityPattern':
return one(
'Maturity Pattern',
'Maturity Pattern Register',
[
'Bucket',
'Holdings',
'Face Value'
],
[
22,
14,
18
],
this.maturityBuckets.map((b) => [
b.label,
b.count,
b.faceValue
])
);
case 'govtSecList':
return one(
'Govt Security List',
@@ -535,27 +631,33 @@ export class InvestmentReportsComponent {
case 'shiftingReport':
return one(
'Shifting Report',
'Shifting Report — SLR Market Deviation ≥ 1%',
'Shifting Report — Govt Securities transferred to another script / category',
[
'Cert No',
'Script',
'Purchase Rate',
'Market Price',
'Deviation %'
'Date',
'Deal No',
'From',
'To',
'Units',
'Book Value',
'Voucher No'
],
[
14,
30,
12,
16,
34,
34,
12,
16,
14
20
],
this.shiftingCandidates.map((x) => [
x.certNo,
x.scriptName,
x.purchaseRate,
x.marketPrice,
x.deviationPct
this.transfers.map((x) => [
x.transferDate,
x.dealNo,
`${x.fromScriptCode} ${x.fromScriptDesc}`,
`${x.toScriptCode} ${x.toScriptDesc}`,
x.units,
x.bookValue,
x.voucherNo ?? ''
])
);
case 'portfolio':
+173
View File
@@ -15,6 +15,7 @@ import {
HoldingStatus,
InstrumentType,
InvestmentHolding,
InvestmentTxn,
PARTY_MASTER,
PartyMaster,
SCRIPT_MASTER,
@@ -756,6 +757,178 @@ export function toHolding(p: InvestmentPurchaseDto): InvestmentHolding {
};
}
/** A security transfer shows the book value carried to the new security. */
export function toTransferTxn(x: InvestmentSecurityTransferDto): InvestmentTxn {
return {
certNo: x.certNo,
instrumentType: x.bondType as InstrumentType,
txnType: 'TRANSFER',
voucherNo: x.voucherNo ?? `TRF/${x.dealNo}/${x.id}`,
txnDate: x.transferDate,
amount: x.bookValue,
particulars: `GOI category transfer - Deal ${x.dealNo}: ${x.fromScriptCode} ${x.fromScriptDesc} → ${x.toScriptCode} ${x.toScriptDesc}`,
glCode: x.toGlCode,
glName: x.glTransactionId
? `${x.toGlName} / Cr ${x.fromGlCode} (txn ${x.glTransactionId})`
: `${x.toGlName} (same GL, no entry)`
};
}
/** A sale or redemption shows the money into the bank; a pending sale is not in the GL yet. */
export function toSaleTxn(x: InvestmentSaleDto): InvestmentTxn {
if (x.saleType === 'AMORTISE') {
return {
certNo: x.certNo,
instrumentType: x.bondType as InstrumentType,
txnType: 'AMORTIZE',
voucherNo: x.voucherNo ?? `AMZ/${x.dealNo}/${x.id}`,
txnDate: x.settlementDate,
amount: x.bookValueSold,
particulars: `${x.remarks ?? 'Premium amortisation'} - Deal ${x.dealNo} - ${x.scriptDesc} (${x.accruedDays} days)`,
glCode: '500604',
glName: `Amortisation of Premium / Cr Premium on Investments (txn ${x.glTransactionId})`
};
}
if (x.saleType === 'WRITEOFF') {
return {
certNo: x.certNo,
instrumentType: x.bondType as InstrumentType,
txnType: 'WRITE_OFF',
voucherNo: x.voucherNo ?? `WOF/${x.dealNo}/${x.id}`,
txnDate: x.settlementDate,
amount: x.bookValueSold,
particulars: `Written off - Deal ${x.dealNo} - ${x.scriptDesc}${x.remarks ? ' - ' + x.remarks : ''}`,
glCode: '500605',
glName: `Loss on Investments / Cr ${x.investmentGlCode} (txn ${x.glTransactionId})`
};
}
const redeem = x.saleType === 'REDEEM';
const pl =
x.profitLoss >= 0
? `profit ₹${x.profitLoss}`
: redeem
? `premium written off ₹${-x.profitLoss}`
: `loss ₹${-x.profitLoss}`;
return {
certNo: x.certNo,
instrumentType: x.bondType as InstrumentType,
txnType: x.saleType,
voucherNo: x.voucherNo ?? `SALE/${x.dealNo}/${x.id}`,
txnDate: x.settlementDate,
amount: x.netAmount,
particulars: redeem
? `Redeemed at maturity - Deal ${x.dealNo} - ${x.scriptDesc} (${pl})`
: `Sold ${x.units} units @ ${x.saleRate} to ${x.partyName ?? ''} - Deal ${x.dealNo} - ${x.scriptDesc} (${pl})`,
glCode: x.status === 'SETTLED' ? x.bankGlCode : '',
glName:
x.status === 'SETTLED'
? `${x.bankGlName} / Cr ${x.investmentGlCode} (txn ${x.glTransactionId})`
: 'Not posted (pending settlement)'
};
}
/** A coupon shows the cash received into the bank; a provision the interest booked as receivable. */
export function toInterestTxn(x: InvestmentInterestPostingDto): InvestmentTxn {
const coupon = x.lineType === 'COUPON';
return {
certNo: x.certNo,
instrumentType: x.bondType as InstrumentType,
txnType: 'INTEREST',
voucherNo: x.voucherNo,
txnDate: x.intDate,
amount: coupon ? x.grossInterest : x.amount,
particulars: `${coupon ? 'Coupon' : 'Provision'} ${x.fromDate} to ${x.toDate} - Deal ${x.dealNo} - ${x.scriptDesc}`,
glCode: x.drGlCode,
glName: `${x.drGlName} / Cr ${x.incomeGlCode} (txn ${x.glTransactionId})`
};
}
/** A purchase reaches the GL only when it is settled, dated the settlement date. */
export function toPurchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn {
const posted = !!d.glTransactionId;
return {
certNo: d.certNo,
instrumentType: d.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${d.dealNo}`,
txnDate: d.settlementDate,
amount: d.settlementAmount,
particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]`,
glCode: posted ? d.investmentGlCode : '',
glName: posted ? `${d.investmentGlName} (txn ${d.glTransactionId})` : 'Not posted (pending settlement)'
};
}
/** An FD as a row of the holdings registers: principal as face value, closed FDs shown as matured. */
export function toFdHolding(f: InvestmentFdDto): InvestmentHolding {
return {
id: f.id,
dealNo: f.dealNo,
certNo: f.certNo ?? '',
instrumentType: 'FD',
scriptCode: '',
scriptName: `FD @ ${f.intRate ?? 0}%`,
party: f.partyName ?? '',
partyCode: f.partyCode,
subType: '',
faceValue: f.principal ?? 0,
purchaseRate: 100,
units: 1,
purchaseDate: f.dealDate ?? '',
maturityDate: f.maturityDate ?? '',
interestRate: f.intRate ?? 0,
interestFreq: f.payout === 'HALF_YEARLY' ? 'HALF_YEARLY' : f.payout === 'YEARLY' ? 'YEARLY' : 'CUMULATIVE',
glCode: f.fdGlCode ?? '',
glName: f.fdGlName ?? '',
crToAc: f.bankGlCode ?? '',
brokerCode: '',
status: f.status === 'ACTIVE' ? 'ACTIVE' : 'MATURED',
bookValue: f.principal
};
}
/** The GL entries of an FD: placing it, each posted interest line, and its closure. */
export function toFdTxns(f: InvestmentFdDto, rows: InvestmentFdInterestRowDto[]): InvestmentTxn[] {
const base = { certNo: f.certNo ?? '', instrumentType: 'FD' as InstrumentType };
const out: InvestmentTxn[] = [
{
...base,
txnType: 'PURCHASE',
voucherNo: f.voucherNo ?? `FD/${f.dealNo}`,
txnDate: f.dealDate ?? '',
amount: f.principal ?? 0,
particulars: `FD placed - Deal ${f.dealNo} - ${f.partyName} @ ${f.intRate}% to ${f.maturityDate}`,
glCode: f.fdGlCode,
glName: `${f.fdGlName} / Cr ${f.bankGlCode} (txn ${f.glTransactionId})`
}
];
for (const r of rows.filter((x) => x.posted)) {
out.push({
...base,
txnType: 'INTEREST',
voucherNo: r.voucherNo ?? `FDI/${f.dealNo}/${r.intDate}`,
txnDate: r.intDate,
amount: r.lineType === 'PROVISION' ? r.amount : r.grossInterest,
particulars: `${r.lineType === 'PROVISION' ? 'Provision' : 'Interest payout'} ${r.fromDate} to ${r.toDate} - Deal ${f.dealNo} - ${f.partyName}${r.tds ? ` (TDS ₹${r.tds})` : ''}`,
glCode: f.interestGlCode,
glName: `Cr ${f.interestGlName} (txn ${r.glTransactionId})`
});
}
if (f.closedOnDate) {
out.push({
...base,
txnType: 'REDEEM',
voucherNo: f.closureVoucherNo ?? `FDC/${f.dealNo}`,
txnDate: f.closedOnDate,
amount: f.closureAmount ?? 0,
particulars: `FD ${(f.closureType ?? 'closed').toLowerCase()} - Deal ${f.dealNo} - ${f.partyName} (interest ₹${f.closureInterest ?? 0}${f.closureTds ? `, TDS ₹${f.closureTds}` : ''}${f.closurePenal ? `, penal ₹${f.closurePenal}` : ''})`,
glCode: f.bankGlCode,
glName: `${f.bankGlName} / Cr ${f.fdGlCode} (txn ${f.closureGlTransactionId})`
});
}
return out;
}
/** One line of a holding's interest schedule (see InvestmentInterestScheduleData in the backend). */
export interface InvestmentInterestRowDto {
type: 'COUPON' | 'PROVISION';