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, InvestmentNonSlrLimitDto,
InvestmentService, InvestmentService,
apiErrorMessage, apiErrorMessage,
toHolding toHolding,
toInterestTxn,
toPurchaseTxn,
toSaleTxn,
toTransferTxn
} from '../investment.service'; } from '../investment.service';
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND'; type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
@@ -222,7 +226,7 @@ export class BondInvestmentComponent implements OnInit {
this.investmentService.getPurchases().subscribe({ this.investmentService.getPurchases().subscribe({
next: (purchases) => { next: (purchases) => {
this.holdings = purchases.map(toHolding); this.holdings = purchases.map(toHolding);
this.txns = purchases.map((d) => this.purchaseTxn(d)); this.txns = purchases.map(toPurchaseTxn);
this.purchaseForm = this.blankPurchaseForm(); this.purchaseForm = this.blankPurchaseForm();
this.loadNonSlrLimit(); this.loadNonSlrLimit();
this.loadInterestLog(); this.loadInterestLog();
@@ -253,115 +257,13 @@ export class BondInvestmentComponent implements OnInit {
); );
this.txns = [ this.txns = [
...others, ...others,
...postings.map((x) => this.interestTxn(x)), ...postings.map(toInterestTxn),
...sales.map((x) => this.saleTxn(x)), ...sales.map(toSaleTxn),
...transfers.map((x) => this.transferTxn(x)) ...transfers.map(toTransferTxn)
].sort((a, b) => parseDDMMYYYY(b.txnDate).getTime() - parseDDMMYYYY(a.txnDate).getTime()); ].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'; activeTab: BondTab = 'holdings';
/** Purchase Entry shows only the mandatory fields; the rest sit under "More Details". */ /** Purchase Entry shows only the mandatory fields; the rest sit under "More Details". */
showMoreDetails = false; showMoreDetails = false;
@@ -12,34 +12,50 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT</div> <div class="pg-eyebrow">TREASURY · INVESTMENT</div>
<h1 class="pg-title">Investment Portfolio Dashboard</h1> <h1 class="pg-title">Investment Portfolio Dashboard</h1>
<p class="pg-sub"> <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 }}. {{ asOnDate }}.
</p> </p>
</div> </div>
<div class="pg-toolbar"> <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' }} {{ generating ? 'Exporting…' : 'Export to Excel' }}
</button> </button>
</div> </div>
</header> </header>
@if (error) {
<div class="pg-card">
<p class="inv-hint inv-hint--bad">{{ error }}</p>
</div>
}
<div class="inv-kpis"> <div class="inv-kpis">
<div class="inv-kpi"> <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__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>
<div class="inv-kpi"> <div class="inv-kpi">
<span class="inv-kpi__label">Total Invested (Face Value)</span> <span class="inv-kpi__label">Total Invested (Book Value)</span>
<span class="inv-kpi__value">₹{{ totalFaceValue | number: '1.2-2' }}</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>
<div class="inv-kpi"> <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__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>
<div class="inv-kpi"> <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__value">₹{{ totalMaturityValue | number: '1.2-2' }}</span>
<span class="inv-kpi__sub">Securities at face value, FDs with interest</span>
</div> </div>
</div> </div>
@@ -51,7 +67,9 @@
<th>Instrument Type</th> <th>Instrument Type</th>
<th class="num">Holdings</th> <th class="num">Holdings</th>
<th class="num">Face Value</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 class="num col-net">Maturity Value</th>
<th></th> <th></th>
</tr> </tr>
@@ -62,6 +80,8 @@
<td>{{ s.label }}</td> <td>{{ s.label }}</td>
<td class="num">{{ s.count }}</td> <td class="num">{{ s.count }}</td>
<td class="num">₹{{ s.faceValue | number: '1.2-2' }}</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-in">₹{{ s.accruedInterest | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ s.maturityValue | number: '1.2-2' }}</td> <td class="num col-net">₹{{ s.maturityValue | number: '1.2-2' }}</td>
<td> <td>
@@ -75,6 +95,8 @@
<td>Total</td> <td>Total</td>
<td class="num">{{ totalHoldings }}</td> <td class="num">{{ totalHoldings }}</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</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">₹{{ totalAccruedInterest | number: '1.2-2' }}</td>
<td class="num">₹{{ totalMaturityValue | number: '1.2-2' }}</td> <td class="num">₹{{ totalMaturityValue | number: '1.2-2' }}</td>
<td></td> <td></td>
@@ -84,93 +106,70 @@
</div> </div>
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div> <div class="pos-section-label">RBI Limits</div>
<p class="pg-sub"> @if (limitNote) {
Govt Security, Non-SLR, Mutual Fund and Share targets trace to RBI's SLR floor (18% of NDTL) and Non-SLR ceiling <p class="inv-hint inv-hint--bad">{{ limitNote }}</p>
(≤10% of deposits) for Urban Co-operative Banks; Fixed Deposit and Bond are board-approved policy choices within }
the room that framework leaves. @if (limits.length) {
</p> <table class="pg-table">
<table class="pg-table"> <thead>
<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) {
<tr> <tr>
<td>{{ a.label }}</td> <th>Limit</th>
<td> <th>Basis</th>
<span class="inv-badge" [class.inv-badge--matured]="a.isRbiLinked"> <th class="num">Allowed</th>
{{ a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy' }} <th class="num">Used</th>
</span> <th class="num">Used %</th>
</td> <th>Status</th>
<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>
</tr> </tr>
} </thead>
</tbody> <tbody>
<tfoot> @for (l of limits; track l.label) {
<tr class="cctr-total-row"> <tr>
<td>Total</td> <td>{{ l.label }}</td>
<td class="num">100%</td> <td>{{ l.basis }}</td>
<td class="num">100%</td> <td class="num">₹{{ l.limit | number: '1.2-2' }}</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td> <td class="num">₹{{ l.used | number: '1.2-2' }}</td>
<td colspan="2"></td> <td class="num">{{ l.usedPct === null ? '—' : (l.usedPct | number: '1.1-1') + '%' }}</td>
</tr> <td>
</tfoot> <span class="inv-badge" [class.inv-badge--active]="l.within" [class.inv-badge--written_off]="!l.within">
</table> {{ l.within ? 'Within' : 'Above limit' }}
</span>
</td>
</tr>
}
</tbody>
</table>
}
</div> </div>
<div class="pg-card"> <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) { @if (maturingSoon.length === 0) {
<div class="pg-empty">No holdings maturing in the next 90 days.</div> <div class="pg-empty">No holdings maturing in the next 90 days.</div>
} @else { } @else {
<div> <div>
@for (h of maturingSoon; track h.certNo) { @for (m of maturingSoon; track m.dealNo) {
<div class="inv-maturity-row"> <div class="inv-maturity-row">
<span> <span>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ m.dealNo }}</span>
{{ h.scriptName }} — {{ h.party }} ({{ instrumentLabel(h.instrumentType) }}) {{ m.particulars }} ({{ m.instrument }})
</span> </span>
<span> <span>
Matures {{ h.maturityDate }} · Matures {{ m.maturityDate }} · ₹{{ m.amount | number: '1.2-2' }} ·
<span <span
class="inv-maturity-days" class="inv-maturity-days"
[class.inv-maturity-days--soon]="daysLeft(h) <= 30" [class.inv-maturity-days--soon]="m.days <= 30"
[class.inv-maturity-days--ok]="daysLeft(h) > 30" [class.inv-maturity-days--ok]="m.days > 30"
> >
{{ daysLeft(h) }} days {{ m.days < 0 ? 'Overdue ' + -m.days + ' days' : m.days + ' days' }}
</span> </span>
</span> </span>
</div> </div>
} }
</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> </div>
@@ -179,19 +178,17 @@
<div class="inv-nav-grid" style="padding: 16px"> <div class="inv-nav-grid" style="padding: 16px">
<a class="inv-nav-card" routerLink="/investment/fd"> <a class="inv-nav-card" routerLink="/investment/fd">
<span class="inv-nav-card__label">Fixed Deposit</span> <span class="inv-nav-card__label">Fixed Deposit</span>
<span class="inv-nav-card__count">{{ summaries[0].count }}</span> <span class="inv-nav-card__count">{{ countOf('FD') }}</span>
<span class="inv-nav-card__value">Purchase / Interest / Sell / Write-off</span> <span class="inv-nav-card__value">Purchase / Interest / Maturity / Renewal</span>
</a> </a>
<a class="inv-nav-card" routerLink="/investment/bonds"> <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__label">Govt Security / Non-SLR / Bonds / MF</span>
<span class="inv-nav-card__count">{{ <span class="inv-nav-card__count">{{ countOf('GOVT_SECURITY', 'NON_SLR', 'BOND', 'MUTUAL_FUND') }}</span>
summaries[1].count + summaries[2].count + summaries[3].count + summaries[4].count
}}</span>
<span class="inv-nav-card__value">Purchase / Interest / Sell / Amortize</span> <span class="inv-nav-card__value">Purchase / Interest / Sell / Amortize</span>
</a> </a>
<a class="inv-nav-card" routerLink="/investment/share"> <a class="inv-nav-card" routerLink="/investment/share">
<span class="inv-nav-card__label">Share Entry</span> <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> <span class="inv-nav-card__value">Purchase / Dividend / Sell / Write-off</span>
</a> </a>
<a class="inv-nav-card" routerLink="/investment/interest"> <a class="inv-nav-card" routerLink="/investment/interest">
@@ -6,21 +6,23 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * 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 { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { import { forkJoin } from 'rxjs';
ALL_HOLDINGS, import { INSTRUMENT_LABELS, InstrumentType, parseDDMMYYYY, todayDDMMYYYY } from '../investment-data';
ALLOCATION_TARGET_IS_RBI_LINKED,
INSTRUMENT_LABELS,
InstrumentType,
InvestmentHolding,
TARGET_ALLOCATION_PCT,
accruedInterest,
daysToMaturity,
maturityAmount
} from '../investment-data';
import { exportWorkbook } from '../investment-export'; import { exportWorkbook } from '../investment-export';
import {
InvestmentBucketReportDto,
InvestmentBucketRowDto,
InvestmentFdDto,
InvestmentFdLimitDto,
InvestmentInterestReportDto,
InvestmentNonSlrLimitDto,
InvestmentPurchaseDto,
InvestmentService,
apiErrorMessage
} from '../investment.service';
interface InstrumentSummary { interface InstrumentSummary {
type: InstrumentType; type: InstrumentType;
@@ -28,25 +30,45 @@ interface InstrumentSummary {
route: string; route: string;
count: number; count: number;
faceValue: number; faceValue: number;
bookValue: number;
accruedInterest: number; accruedInterest: number;
maturityValue: number; maturityValue: number;
sharePct: number;
} }
interface AllocationRow { interface LimitRow {
type: InstrumentType;
label: string; label: string;
targetPct: number; basis: string;
actualPct: number; limit: number | null;
actualValue: number; used: number;
variancePct: number; usedPct: number | null;
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated'; within: boolean;
/** 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;
} }
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never /** A holding maturing soon, or past maturity and not yet redeemed / closed. */
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */ 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({ @Component({
selector: 'mifosx-investment-dashboard', selector: 'mifosx-investment-dashboard',
templateUrl: './investment-dashboard.component.html', templateUrl: './investment-dashboard.component.html',
@@ -60,107 +82,216 @@ interface AllocationRow {
RouterLink RouterLink
] ]
}) })
export class InvestmentDashboardComponent { export class InvestmentDashboardComponent implements OnInit {
asOnDate = ''; private readonly investmentService = inject(InvestmentService);
asOnDate = todayDDMMYYYY();
loading = false;
error = '';
generating = false; generating = false;
toastMsg = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; 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() { summaries: InstrumentSummary[] = [];
const d = new Date(); limits: LimitRow[] = [];
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; limitNote = '';
maturingSoon: MaturingRow[] = [];
totalReceivable = 0;
totalProvisioned = 0;
ngOnInit(): void {
this.load();
} }
private holdingsOf(type: InstrumentType): InvestmentHolding[] { load(): void {
return ALL_HOLDINGS.filter((h) => h.instrumentType === type); const today = this.asOnDate;
} const d = parseDDMMYYYY(today);
const fyStart = `01-04-${d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1}`;
get summaries(): InstrumentSummary[] { this.loading = true;
return this.types.map(({ type, route }) => { this.error = '';
const holdings = this.holdingsOf(type); forkJoin({
return { bucket: this.investmentService.getBucketReport(today),
type, interest: this.investmentService.getInterestReport(fyStart, today),
label: INSTRUMENT_LABELS[type], purchases: this.investmentService.getPurchases(),
route, fds: this.investmentService.getFds(),
count: holdings.length, fdQuote: this.investmentService.quoteFd({ amount: 0, intRate: 0, dealDate: today }),
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0), nonSlr: this.investmentService.getNonSlrLimit(null, today, 0)
accruedInterest: holdings.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0), }).subscribe({
maturityValue: holdings.reduce((s, h) => s + maturityAmount(h), 0) 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 private build(
* (by invested/face value) — a ±2 percentage-point band is treated as "Within Target" since bucket: InvestmentBucketReportDto,
* a treasury desk can't rebalance to the exact decimal every day. */ interest: InvestmentInterestReportDto,
get allocationVsTarget(): AllocationRow[] { purchases: InvestmentPurchaseDto[],
const total = this.totalFaceValue; fds: InvestmentFdDto[],
return this.types.map(({ type }) => { fdLimit: InvestmentFdLimitDto | null,
const value = this.holdingsOf(type).reduce((s, h) => s + h.faceValue, 0); nonSlr: InvestmentNonSlrLimitDto
const actualPct = total ? (value / total) * 100 : 0; ): void {
const targetPct = TARGET_ALLOCATION_PCT[type]; const purchaseById = new Map<number, InvestmentPurchaseDto>(
const variancePct = +(actualPct - targetPct).toFixed(1); purchases.map((p) => [
const statusLabel: AllocationRow['statusLabel'] = p.id,
Math.abs(variancePct) <= 2 ? 'Within Target' : variancePct > 0 ? 'Over-Allocated' : 'Under-Allocated'; p
return { ])
type, );
label: INSTRUMENT_LABELS[type], const fdById = new Map<number, InvestmentFdDto>(
targetPct, fds
actualPct: +actualPct.toFixed(1), .filter((f) => f.id != null)
actualValue: value, .map((f) => [
variancePct, f.id as number,
statusLabel, f
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type] ])
}; );
}); 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 { get totalHoldings(): number {
return ALL_HOLDINGS.length; return this.summaries.reduce((t, s) => t + s.count, 0);
} }
get totalFaceValue(): number { 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 { 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 { 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 { get overdueCount(): number {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE').length; return this.maturingSoon.filter((m) => m.days < 0).length;
} }
get maturedCount(): number { /** Holdings of one or more instrument types, for the Quick Entry cards. */
return ALL_HOLDINGS.filter((h) => h.status === 'MATURED').length; countOf(...types: InstrumentType[]): number {
} return this.summaries.filter((s) => types.includes(s.type)).reduce((t, s) => t + s.count, 0);
/** 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];
} }
async exportExcel(): Promise<void> { async exportExcel(): Promise<void> {
@@ -176,54 +307,97 @@ export class InvestmentDashboardComponent {
'Instrument Type', 'Instrument Type',
'Holdings', 'Holdings',
'Face Value', 'Face Value',
'Accrued Interest', 'Book Value',
'Share %',
'Interest Receivable',
'Maturity Value' 'Maturity Value'
], ],
widths: [ widths: [
26, 22,
12, 10,
18, 18,
18, 18,
10,
18,
18 18
], ],
rows: this.summaries.map((s) => [ rows: [
s.label, ...this.summaries.map((s) => [
s.count, s.label,
s.faceValue, s.count,
s.accruedInterest, s.faceValue,
s.maturityValue 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', sheetName: 'RBI Limits',
title: 'Portfolio Allocation — Target vs Actual', title: 'RBI Limits',
subtitle: `As on ${this.asOnDate}`, subtitle: `As on ${this.asOnDate}`,
headers: [ headers: [
'Instrument Type', 'Limit',
'Target Basis', 'Basis',
'Target %', 'Allowed',
'Actual %', 'Used',
'Actual Value', 'Used %',
'Variance %',
'Status' 'Status'
], ],
widths: [ widths: [
26, 26,
20, 40,
12,
12,
18, 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, 12,
10,
18 18
], ],
rows: this.allocationVsTarget.map((a) => [ rows: this.maturingSoon.map((m) => [
a.label, m.dealNo,
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy', m.instrument,
a.targetPct, m.particulars,
a.actualPct, m.maturityDate,
a.actualValue, m.days,
a.variancePct, m.amount
a.statusLabel
]) ])
} }
], ],
@@ -12,17 +12,26 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div> <div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
<h1 class="pg-title">Investment Reports</h1> <h1 class="pg-title">Investment Reports</h1>
<p class="pg-sub"> <p class="pg-sub">
Balance reports, ledgers, registers, maturity lists and the consolidated portfolio — all derived live from the Balance reports, ledgers, registers, maturity lists and the consolidated portfolio from the saved deals, FDs and
same holdings/transactions used by the Entry screens. their GL entries, as on {{ asOnDate }}.
</p> </p>
</div> </div>
<div class="pg-toolbar"> <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' }} {{ generating ? 'Exporting…' : 'Export to Excel' }}
</button> </button>
</div> </div>
</header> </header>
@if (error) {
<div class="pg-card">
<p class="inv-hint inv-hint--bad">{{ error }}</p>
</div>
}
<div class="cctr-tabs"> <div class="cctr-tabs">
<button <button
type="button" type="button"
@@ -124,7 +133,7 @@
: activeReport === 'shareBalance' : activeReport === 'shareBalance'
? shareHoldings ? shareHoldings
: fdHoldings; : fdHoldings;
track h.certNo track h.instrumentType + ':' + h.id
) { ) {
<tr> <tr>
<td> <td>
@@ -138,6 +147,12 @@
<span [class]="statusClass(h)">{{ h.status }}</span> <span [class]="statusClass(h)">{{ h.status }}</span>
</td> </td>
</tr> </tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">
{{ activeReport === 'shareBalance' ? 'Share entry is not live yet.' : 'No holdings.' }}
</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -280,7 +295,7 @@
: activeReport === 'amortizeLedger' : activeReport === 'amortizeLedger'
? amortizeTxns ? amortizeTxns
: dealSlips; : dealSlips;
track t.voucherNo track $index
) { ) {
<tr> <tr>
<td>{{ t.txnDate }}</td> <td>{{ t.txnDate }}</td>
@@ -303,7 +318,10 @@
} }
@if (activeReport === 'intDueRegister') { @if (activeReport === 'intDueRegister') {
<div class="pg-card"> <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"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
@@ -311,11 +329,11 @@
<th>Script / Party</th> <th>Script / Party</th>
<th>Frequency</th> <th>Frequency</th>
<th class="num">Rate</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> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of interestDueRegister; track h.certNo) { @for (h of interestDueRegister; track h.instrumentType + ':' + h.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ h.certNo }}</span>
@@ -325,6 +343,10 @@
<td class="num">{{ h.interestRate }}%</td> <td class="num">{{ h.interestRate }}%</td>
<td class="num col-in">₹{{ h.dueAmount | number: '1.2-2' }}</td> <td class="num col-in">₹{{ h.dueAmount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No interest receivable.</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -344,7 +366,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of slrBondRegister; track h.certNo) { @for (h of slrBondRegister; track h.instrumentType + ':' + h.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ h.certNo }}</span>
@@ -372,29 +394,33 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (t of receivedInterestTxns; track t.voucherNo) { @for (t of receivedInterestTxns; track $index) {
<tr> <tr>
<td>{{ t.txnDate }}</td> <td>{{ t.txnDate }}</td>
<td>{{ t.certNo }}</td> <td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td> <td>{{ t.particulars }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td> <td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty {
<tr>
<td colspan="4" class="pg-empty">No interest received yet.</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pg-card"> <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"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Cert No</th> <th>Cert No</th>
<th>Script / Party</th> <th>Script / Party</th>
<th class="num col-out">Accrued (Pending)</th> <th class="num col-out">Receivable</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of pendingInterestHoldings; track h.certNo) { @for (h of pendingInterestHoldings; track h.instrumentType + ':' + h.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ h.certNo }}</span>
@@ -487,11 +513,11 @@
<th>Cert No</th> <th>Cert No</th>
<th>Party</th> <th>Party</th>
<th class="num">Face Value</th> <th class="num">Face Value</th>
<th>Matured On</th> <th>Maturity Date</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of maturedFdList; track h.certNo) { @for (h of maturedFdList; track h.instrumentType + ':' + h.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ h.certNo }}</span>
@@ -524,7 +550,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of maturingList; track h.certNo) { @for (h of maturingList; track h.instrumentType + ':' + h.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ h.certNo }}</span>
@@ -543,24 +569,12 @@
@if (activeReport === 'maturityPattern') { @if (activeReport === 'maturityPattern') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Maturity Pattern Register</div> <div class="pos-section-label">Maturity Pattern Register</div>
<table class="pg-table"> <p class="pg-sub" style="padding: 0 16px">
<thead> The maturity pattern (up to 1 year, 1–3, 3–5 and over 5 years) is the Bucket Wise Investment report.
<tr> </p>
<th>Bucket</th> <div class="inv-form-actions" style="padding: 8px 16px">
<th class="num">Holdings</th> <a class="pg-btn pg-btn--primary" routerLink="/investment/bucket-report">Open Bucket Wise Investment</a>
<th class="num">Face Value</th> </div>
</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>
</div> </div>
} }
@if ( @if (
@@ -601,7 +615,7 @@
: activeReport === 'shareSecList' : activeReport === 'shareSecList'
? shareSecList ? shareSecList
: mutualFundList; : mutualFundList;
track h.certNo track h.instrumentType + ':' + h.id
) { ) {
<tr> <tr>
<td> <td>
@@ -615,6 +629,12 @@
<span [class]="statusClass(h)">{{ h.status }}</span> <span [class]="statusClass(h)">{{ h.status }}</span>
</td> </td>
</tr> </tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">
{{ activeReport === 'shareSecList' ? 'Share entry is not live yet.' : 'No holdings.' }}
</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -622,35 +642,35 @@
} }
@if (activeReport === 'shiftingReport') { @if (activeReport === 'shiftingReport') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label"> <div class="pos-section-label">Shifting Report — Govt Securities transferred to another script / category</div>
Shifting Report — SLR Securities with Market Deviation ≥ 1% (AFS/HTM review candidates)
</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Cert No</th> <th>Date</th>
<th>Script</th> <th>Deal No</th>
<th class="num">Purchase Rate</th> <th>From</th>
<th class="num">Market Price</th> <th>To</th>
<th class="num">Deviation %</th> <th class="num">Units</th>
<th class="num">Book Value</th>
<th>Voucher</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of shiftingCandidates; track h.certNo) { @for (x of transfers; track x.id) {
<tr> <tr>
<td>{{ x.transferDate }}</td>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ x.dealNo }}</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 }}%
</td> </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> </tr>
} @empty { } @empty {
<tr> <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> </tr>
} }
</tbody> </tbody>
@@ -687,7 +707,7 @@
<tfoot> <tfoot>
<tr class="cctr-total-row"> <tr class="cctr-total-row">
<td>Total</td> <td>Total</td>
<td class="num"></td> <td class="num">{{ portfolioTotalCount }}</td>
<td class="num">₹{{ portfolioTotalFaceValue | number: '1.2-2' }}</td> <td class="num">₹{{ portfolioTotalFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ portfolioTotalAccrued | number: '1.2-2' }}</td> <td class="num">₹{{ portfolioTotalAccrued | number: '1.2-2' }}</td>
<td class="num">₹{{ portfolioTotalMaturity | 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/. * 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 { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { import {
ALL_HOLDINGS,
ALL_TXNS,
FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
MUTUAL_FUND_HOLDINGS,
SHARE_HOLDINGS,
SCRIPT_MARKET_DETAIL,
InstrumentType, InstrumentType,
InvestmentHolding, InvestmentHolding,
InvestmentTxn, InvestmentTxn,
INSTRUMENT_LABELS, INSTRUMENT_LABELS,
accruedInterest,
maturityAmount,
daysToMaturity, daysToMaturity,
parseDDMMYYYY,
todayDDMMYYYY todayDDMMYYYY
} from '../investment-data'; } from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export'; 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'; type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
/** /**
* ~20 legacy Investment reports, grouped into 4 tabs. Every figure here is derived from the * ~20 legacy Investment reports in 4 tabs, on live data: the saved purchases and FDs as holdings, and their GL
* same ALL_HOLDINGS/ALL_TXNS arrays used by the Entry screens and Dashboard — so a report can * entries (settlements, interest, sales, redemptions, amortisation, transfers, FD placements and closures) as the
* never drift out of sync with what a Purchase/Sell/Interest entry actually recorded. * ledgers. Interest receivable comes from the Interest Received & Receivable report, market rates from the Daily
* Script Master.
*/ */
@Component({ @Component({
selector: 'mifosx-investment-reports', selector: 'mifosx-investment-reports',
@@ -44,37 +53,176 @@ type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
'../investment-shared.scss' '../investment-shared.scss'
], ],
standalone: true, standalone: true,
imports: [CommonModule] imports: [
CommonModule,
RouterLink
]
}) })
export class InvestmentReportsComponent { export class InvestmentReportsComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
activeGroup: ReportGroup = 'balance'; activeGroup: ReportGroup = 'balance';
activeReport = 'govtSecBalance'; activeReport = 'govtSecBalance';
asOnDate = todayDDMMYYYY(); asOnDate = todayDDMMYYYY();
loading = false;
error = '';
generating = false; generating = false;
toastMsg = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; 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 { setGroup(group: ReportGroup, defaultReport: string): void {
this.activeGroup = group; this.activeGroup = group;
this.activeReport = defaultReport; this.setReport(defaultReport);
} }
setReport(report: string): void { setReport(report: string): void {
this.activeReport = report; this.activeReport = report;
if (report === 'fdLedger' || report === 'pendingReceived') {
this.loadFdInterest();
}
} }
instrumentLabel(type: InstrumentType): string { instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type]; return INSTRUMENT_LABELS[type];
} }
/** Interest earned up to the as-on date and not yet received (Interest Received & Receivable report). */
accrued(h: InvestmentHolding): number { 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 { 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 { daysLeft(h: InvestmentHolding): number {
@@ -85,23 +233,32 @@ export class InvestmentReportsComponent {
return `inv-badge inv-badge--${h.status.toLowerCase()}`; 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 { 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 -------------------- // -------------------- Balance Reports --------------------
get govtSecHoldings(): InvestmentHolding[] { get govtSecHoldings(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS; return this.active('GOVT_SECURITY');
} }
get shareHoldings(): InvestmentHolding[] { get shareHoldings(): InvestmentHolding[] {
return SHARE_HOLDINGS; return this.active('SHARE');
} }
get fdHoldings(): InvestmentHolding[] { get fdHoldings(): InvestmentHolding[] {
return FD_HOLDINGS; return this.active('FD');
} }
get bankInvestmentList(): Array<{ party: string; count: number; totalFaceValue: number }> { get bankInvestmentList(): Array<{ party: string; count: number; totalFaceValue: number }> {
const map = new Map<string, { 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 }; const row = map.get(h.party) || { party: h.party, count: 0, totalFaceValue: 0 };
row.count += 1; row.count += 1;
row.totalFaceValue += h.faceValue; row.totalFaceValue += h.faceValue;
@@ -112,112 +269,68 @@ export class InvestmentReportsComponent {
// -------------------- Ledgers & Registers -------------------- // -------------------- Ledgers & Registers --------------------
get fdTxns(): InvestmentTxn[] { get fdTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.instrumentType === 'FD'); return this.txns.filter((t) => t.instrumentType === 'FD');
} }
get bondTxns(): InvestmentTxn[] { get bondTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => ([ return this.txns.filter((t) => ([
'GOVT_SECURITY', 'GOVT_SECURITY',
'NON_SLR', 'NON_SLR',
'BOND' 'BOND',
'MUTUAL_FUND'
] as InstrumentType[]).includes(t.instrumentType)); ] as InstrumentType[]).includes(t.instrumentType));
} }
get shareTxns(): InvestmentTxn[] { get shareTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.instrumentType === 'SHARE'); return this.txns.filter((t) => t.instrumentType === 'SHARE');
} }
get amortizeTxns(): InvestmentTxn[] { get amortizeTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'AMORTIZE'); return this.txns.filter((t) => t.txnType === 'AMORTIZE');
} }
get dealSlips(): InvestmentTxn[] { 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[] { 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 }> { get interestDueRegister(): Array<InvestmentHolding & { dueAmount: number }> {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE').map((h) => ({ return this.active()
...h, .map((h) => ({ ...h, dueAmount: this.accrued(h) }))
dueAmount: this.accrued(h) .filter((h) => h.dueAmount > 0);
}));
} }
get receivedInterestTxns(): InvestmentTxn[] { get receivedInterestTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'INTEREST' || t.txnType === 'DIVIDEND'); return this.receivedTxns;
} }
get pendingInterestHoldings(): InvestmentHolding[] { get pendingInterestHoldings(): InvestmentHolding[] {
const receivedCerts = new Set(this.receivedInterestTxns.map((t) => t.certNo)); return this.active().filter((h) => this.accrued(h) > 0);
return ALL_HOLDINGS.filter(
(h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE' && !receivedCerts.has(h.certNo)
);
} }
// -------------------- Maturity & Lists -------------------- // -------------------- Maturity & Lists --------------------
get maturedFdList(): InvestmentHolding[] { get maturedFdList(): InvestmentHolding[] {
return FD_HOLDINGS.filter((h) => h.status === 'MATURED'); return this.ofType('FD').filter((h) => h.status === 'MATURED');
} }
get maturingList(): InvestmentHolding[] { get maturingList(): InvestmentHolding[] {
return [ return [
...FD_HOLDINGS, ...this.active('FD'),
...GOVT_SECURITY_HOLDINGS ...this.active('GOVT_SECURITY')
] ].sort((a, b) => this.daysLeft(a) - this.daysLeft(b));
.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;
} }
get govtSecList(): InvestmentHolding[] { get govtSecList(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS; return this.ofType('GOVT_SECURITY');
} }
get fdSecList(): InvestmentHolding[] { get fdSecList(): InvestmentHolding[] {
return FD_HOLDINGS; return this.ofType('FD');
} }
get shareSecList(): InvestmentHolding[] { get shareSecList(): InvestmentHolding[] {
return SHARE_HOLDINGS; return this.ofType('SHARE');
} }
get mutualFundList(): InvestmentHolding[] { get mutualFundList(): InvestmentHolding[] {
return MUTUAL_FUND_HOLDINGS; return this.ofType('MUTUAL_FUND');
}
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));
} }
// -------------------- Portfolio -------------------- // -------------------- Portfolio --------------------
private readonly portfolioTypes: InstrumentType[] = [ private readonly portfolioTypes: InstrumentType[] = [
'FD',
'GOVT_SECURITY', 'GOVT_SECURITY',
'FD',
'NON_SLR', 'NON_SLR',
'BOND', 'BOND',
'MUTUAL_FUND', 'MUTUAL_FUND',
@@ -231,7 +344,7 @@ export class InvestmentReportsComponent {
maturityValue: number; maturityValue: number;
}> { }> {
return this.portfolioTypes.map((type) => { return this.portfolioTypes.map((type) => {
const holdings = ALL_HOLDINGS.filter((h) => h.instrumentType === type); const holdings = this.active(type);
return { return {
label: INSTRUMENT_LABELS[type], label: INSTRUMENT_LABELS[type],
count: holdings.length, 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 { get portfolioTotalFaceValue(): number {
return this.portfolioSummary.reduce((s, r) => s + r.faceValue, 0); return this.portfolioSummary.reduce((s, r) => s + r.faceValue, 0);
} }
@@ -480,26 +596,6 @@ export class InvestmentReportsComponent {
this.holdingsExportWidths, this.holdingsExportWidths,
this.maturingList.map((h) => this.holdingsExportRow(h)) 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': case 'govtSecList':
return one( return one(
'Govt Security List', 'Govt Security List',
@@ -535,27 +631,33 @@ export class InvestmentReportsComponent {
case 'shiftingReport': case 'shiftingReport':
return one( return one(
'Shifting Report', 'Shifting Report',
'Shifting Report — SLR Market Deviation ≥ 1%', 'Shifting Report — Govt Securities transferred to another script / category',
[ [
'Cert No', 'Date',
'Script', 'Deal No',
'Purchase Rate', 'From',
'Market Price', 'To',
'Deviation %' 'Units',
'Book Value',
'Voucher No'
], ],
[ [
14, 12,
30,
16, 16,
34,
34,
12,
16, 16,
14 20
], ],
this.shiftingCandidates.map((x) => [ this.transfers.map((x) => [
x.certNo, x.transferDate,
x.scriptName, x.dealNo,
x.purchaseRate, `${x.fromScriptCode} ${x.fromScriptDesc}`,
x.marketPrice, `${x.toScriptCode} ${x.toScriptDesc}`,
x.deviationPct x.units,
x.bookValue,
x.voucherNo ?? ''
]) ])
); );
case 'portfolio': case 'portfolio':
+173
View File
@@ -15,6 +15,7 @@ import {
HoldingStatus, HoldingStatus,
InstrumentType, InstrumentType,
InvestmentHolding, InvestmentHolding,
InvestmentTxn,
PARTY_MASTER, PARTY_MASTER,
PartyMaster, PartyMaster,
SCRIPT_MASTER, 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). */ /** One line of a holding's interest schedule (see InvestmentInterestScheduleData in the backend). */
export interface InvestmentInterestRowDto { export interface InvestmentInterestRowDto {
type: 'COUPON' | 'PROVISION'; type: 'COUPON' | 'PROVISION';