feat(investment): bucket wise report, script rating and non-SLR limits on purchase

This commit is contained in:
2026-10-23 12:38:24 +05:30
parent 7bd880387e
commit 731ab5a0e0
12 changed files with 862 additions and 14 deletions
+2 -2
View File
@@ -1528,7 +1528,7 @@
<button mat-menu-item routerLink="/investment/interest-report" tabindex="0">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received &amp; Receivable Report
</button>
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
<button mat-menu-item routerLink="/investment/bucket-report" tabindex="0">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
</button>
<button mat-menu-item routerLink="/deaf" tabindex="0">
@@ -2400,7 +2400,7 @@
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')"
routerLink="/investment/reports"
routerLink="/investment/bucket-report"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment
@@ -336,6 +336,19 @@
<label class="inv-form-field"
><span>Sub Type</span><input type="text" [value]="purchaseForm.subType || '—'" readonly
/></label>
@if (purchaseIsRated) {
<label class="inv-form-field"
><span>Rating</span
><input
type="text"
[value]="
purchaseScript?.rating
? purchaseScript?.rating + (purchaseScript?.listed === false ? ' · Unlisted' : '')
: '— (enter in Script Master)'
"
readonly
/></label>
}
@if (!isMutualFund) {
<label class="inv-form-field"
><span>Int Rate %</span><input type="text" [value]="purchaseForm.intRate ?? '—'" readonly
@@ -403,6 +416,52 @@
</div>
}
@if (purchaseRatingProblem) {
<p class="inv-hint inv-hint--bad">{{ purchaseRatingProblem }}</p>
}
@if (isNonSlrPurchase && nonSlrLimit; as l) {
<div class="pos-section-label">RBI Limit — non-SLR investments (as on {{ l.asOnDate }})</div>
@if (l.nonSlrLimit !== null && l.nonSlrLimit !== undefined) {
<table class="pg-table">
<thead>
<tr>
<th>Limit</th>
<th class="num">Allowed</th>
<th class="num">Now</th>
<th class="num">After this deal</th>
</tr>
</thead>
<tbody>
<tr>
<td>Non-SLR — 10% of total deposits ₹{{ l.totalDeposits | number: '1.2-2' }}</td>
<td class="num">₹{{ l.nonSlrLimit | number: '1.2-2' }}</td>
<td class="num">₹{{ l.nonSlrOutstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ nonSlrCheck?.nonSlrAfter | number: '1.2-2' }}</td>
</tr>
<tr>
<td>
Unlisted — 10% of non-SLR held on {{ l.asOnDate }} ₹{{ l.nonSlrBase | number: '1.2-2' }}
@if (!l.listed) {
<br /><small>This security is unlisted.</small>
}
</td>
<td class="num">₹{{ l.unlistedLimit | number: '1.2-2' }}</td>
<td class="num">₹{{ l.unlistedOutstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ nonSlrCheck?.unlistedAfter | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
}
@if (nonSlrCheck; as c) {
<p class="inv-hint" [class.inv-hint--bad]="!c.withinLimits">
{{ c.message }}
@if (l.nonSlrLimit === null || l.nonSlrLimit === undefined) {
Enter it on the FD screen (RBI Limit section).
}
</p>
}
}
<!-- 3. Accounting preview -->
<div class="pos-section-label">Accounting Preview (posted on Settle, dated the settlement date)</div>
@if (purchaseAccountingPreview.length) {
@@ -466,14 +525,17 @@
}
</select>
</label>
<label class="inv-form-field">
<span>Bond Rating</span>
<select [(ngModel)]="purchaseForm.bondRating">
@for (r of bondRatingOptions; track r) {
<option [value]="r">{{ r }}</option>
}
</select>
</label>
<!-- Bonds, CPs and CDs take their rating from Script Master (shown above), where RBI's minimum is checked. -->
@if (!purchaseIsRated) {
<label class="inv-form-field">
<span>Bond Rating</span>
<select [(ngModel)]="purchaseForm.bondRating">
@for (r of bondRatingOptions; track r) {
<option [value]="r">{{ r }}</option>
}
</select>
</label>
}
<label class="inv-form-field">
<span>SGL with Bank</span>
<select [(ngModel)]="purchaseForm.sglWithBank">
@@ -28,6 +28,7 @@ import {
PAYMENT_MODE_OPTIONS,
DELIVERY_MODE_OPTIONS,
BOND_RATING_OPTIONS,
ELIGIBLE_RATINGS,
INT_TYPE_OPTIONS,
AUTHORITY_OPTIONS,
INVESTMENT_GL_BY_TYPE,
@@ -70,6 +71,7 @@ import {
InvestmentAmortisationDueDto,
InvestmentSaleDto,
InvestmentSecurityTransferDto,
InvestmentNonSlrLimitDto,
InvestmentService,
apiErrorMessage,
toHolding
@@ -222,6 +224,7 @@ export class BondInvestmentComponent implements OnInit {
this.holdings = purchases.map(toHolding);
this.txns = purchases.map((d) => this.purchaseTxn(d));
this.purchaseForm = this.blankPurchaseForm();
this.loadNonSlrLimit();
this.loadInterestLog();
},
error: (err) => this.toast(`Could not load purchases: ${apiErrorMessage(err)}`)
@@ -374,7 +377,10 @@ export class BondInvestmentComponent implements OnInit {
// Master-driven dropdown sources
readonly scriptOptions = SCRIPT_MASTER;
readonly partyOptions = PARTY_MASTER;
readonly subTypeOptions = SUBTYPE_MASTER;
/** Sub types of the chosen bond type only — a mutual fund gets the debt ones, the only kind RBI allows. */
get subTypeOptions(): typeof SUBTYPE_MASTER {
return SUBTYPE_MASTER.filter((s) => s.instrumentType === this.purchaseForm.bondType);
}
readonly brokerOptions = BROKER_MASTER;
readonly sglBankOptions = SGL_BANK_MASTER;
readonly mfProductOptions = MUTUAL_FUND_PRODUCTS;
@@ -444,6 +450,98 @@ export class BondInvestmentComponent implements OnInit {
setBondType(type: BondType): void {
this.bondType = type;
this.purchaseForm.bondType = type;
this.purchaseForm.subType = this.defaultSubType(type);
this.loadNonSlrLimit();
}
private defaultSubType(type: BondType): string {
return type === 'GOVT_SECURITY'
? 'SLR'
: type === 'NON_SLR'
? 'NON SLR'
: type === 'MUTUAL_FUND'
? 'DEBT - LIQUID'
: 'CORPORATE BOND';
}
// -------------------- RBI non-SLR limits --------------------
/** Holdings and limits from the backend for the chosen script and deal date; the new deal is added on screen. */
nonSlrLimit: InvestmentNonSlrLimitDto | null = null;
/** Bonds, CPs, CDs and mutual funds are non-SLR investments, limited by RBI to 10% of deposits. */
get isNonSlrPurchase(): boolean {
return [
'NON_SLR',
'BOND',
'MUTUAL_FUND'
].includes(this.purchaseForm.bondType);
}
loadNonSlrLimit(): void {
const f = this.purchaseForm;
if (!this.isNonSlrPurchase || !f.dealDate) {
this.nonSlrLimit = null;
return;
}
this.investmentService.getNonSlrLimit(this.purchaseScript?.id ?? null, f.dealDate, 0).subscribe({
next: (l: InvestmentNonSlrLimitDto) => (this.nonSlrLimit = l),
error: () => (this.nonSlrLimit = null)
});
}
/** Bonds, CPs and CDs must carry a credit rating at or above the RBI minimum. */
get purchaseIsRated(): boolean {
return [
'NON_SLR',
'BOND'
].includes(this.purchaseForm.bondType);
}
/** RBI minimum rating: "A" for bonds, "A2" for CP / CD; empty when the script is not rated yet. */
get purchaseRatingProblem(): string {
const s = this.purchaseScript;
if (!this.purchaseIsRated || !s) {
return '';
}
if (!s.rating) {
return `Enter the credit rating of ${s.scriptCode} in Script Master first.`;
}
return ELIGIBLE_RATINGS.includes(s.rating)
? ''
: `${s.scriptCode} is rated ${s.rating}; RBI allows bonds rated A or higher and CP / CD rated A2 or higher.`;
}
/** The RBI limits with this deal's book value added — the same checks the backend makes on Save. */
get nonSlrCheck(): { nonSlrAfter: number; unlistedAfter: number; withinLimits: boolean; message: string } | null {
const l = this.nonSlrLimit;
if (!l) {
return null;
}
const amount = this.purchaseBookValue || 0;
const nonSlrAfter = l.nonSlrOutstanding + amount;
const unlistedAfter = l.unlistedOutstanding + (l.listed ? 0 : amount);
const rupees = (v: number) =>
'₹' + v.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fail = (message: string) => ({ nonSlrAfter, unlistedAfter, withinLimits: false, message });
if (l.nonSlrLimit === null || l.nonSlrLimit === undefined) {
return fail(l.message);
}
if (nonSlrAfter > l.nonSlrLimit) {
return fail(
`Non-SLR investments would be ${rupees(nonSlrAfter)}, above the RBI limit of ${rupees(l.nonSlrLimit)}.`
);
}
if (l.unlistedOutstanding > l.unlistedLimit) {
return fail(
`Unlisted non-SLR investments are ${rupees(l.unlistedOutstanding)}, above their limit of ${rupees(l.unlistedLimit)}; no further non-SLR investment until they are within it.`
);
}
if (!l.listed && unlistedAfter > l.unlistedLimit) {
return fail(
`Unlisted non-SLR investments would be ${rupees(unlistedAfter)}, above the RBI limit of ${rupees(l.unlistedLimit)}.`
);
}
return { nonSlrAfter, unlistedAfter, withinLimits: true, message: 'Within the RBI limits.' };
}
get holdingsForType(): InvestmentHolding[] {
@@ -508,7 +606,7 @@ export class BondInvestmentComponent implements OnInit {
faceValue: null,
purchaseRate: 100,
partyCode: '',
subType: type === 'GOVT_SECURITY' ? 'SLR' : type === 'NON_SLR' ? 'NON SLR' : 'CORPORATE BOND',
subType: this.defaultSubType(type),
tbillGlAc: '',
crToAc: SETTLEMENT_BANK_GL_OPTIONS[0]?.glCode ?? '',
premiumAmt: null,
@@ -676,6 +774,7 @@ export class BondInvestmentComponent implements OnInit {
}
this.suggestLastIntDate();
this.loadMarketRate();
this.loadNonSlrLimit();
}
private suggestLastIntDate(): void {
@@ -743,14 +842,19 @@ export class BondInvestmentComponent implements OnInit {
f.periodMonth = p.months;
f.periodDays = p.days;
}
if (s.slrType) {
// A mutual fund keeps its debt sub type; the SLR flag of its script is not a sub type.
if (s.slrType && !this.isMutualFund) {
f.subType = s.slrType === 'NON_SLR' ? 'NON SLR' : 'SLR';
}
if (this.isMutualFund) {
f.productName = s.scriptName;
}
if (this.purchaseIsRated) {
f.bondRating = s.rating ?? '';
}
this.suggestLastIntDate();
this.loadMarketRate();
this.loadNonSlrLimit();
}
/**
@@ -0,0 +1,198 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
<h1 class="pg-title">Bucket Wise Investment</h1>
<p class="pg-sub">
Govt Securities, bonds and FDs held on a date, split by the time left to maturity. Click a bucket to see the
holdings in it.
</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="!report || generating" (click)="exportExcel()">
{{ generating ? 'Generating…' : 'Export to Excel' }}
</button>
</header>
<div class="pg-card">
<div class="inv-form-grid">
<label class="inv-form-field"
><span>As on</span><mifosx-date-ddmmyyyy [(ngModel)]="asOnDate"></mifosx-date-ddmmyyyy
></label>
</div>
<div class="diary-actions">
<button type="button" class="pg-btn pg-btn--primary" [disabled]="loading" (click)="load()">
{{ loading ? 'Loading…' : 'Show' }}
</button>
</div>
@if (error) {
<p class="diary-error">{{ error }}</p>
}
</div>
@if (report; as r) {
<div class="diary-summary">
<div class="diary-tile">
<span class="diary-tile-l">Total Investment</span
><span class="diary-tile-v">₹{{ r.total.totalAmount | number: '1.2-2' }}</span>
</div>
<div class="diary-tile">
<span class="diary-tile-l">Govt Securities (book value)</span
><span class="diary-tile-v">₹{{ r.total.govtBookValue | number: '1.2-2' }}</span>
</div>
@if (hasBonds) {
<div class="diary-tile">
<span class="diary-tile-l">Bonds / Non-SLR (book value)</span
><span class="diary-tile-v">₹{{ r.total.bondBookValue | number: '1.2-2' }}</span>
</div>
}
<div class="diary-tile">
<span class="diary-tile-l">FD with banks</span
><span class="diary-tile-v">₹{{ r.total.fdPrincipal | number: '1.2-2' }}</span>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">As on {{ r.asOnDate }} · by time left to maturity</div>
<table class="pg-table">
<thead>
<tr>
<th>Bucket</th>
<th class="num">Govt Sec</th>
<th class="num">Govt Sec Face Value</th>
<th class="num">Govt Sec Book Value</th>
@if (hasBonds) {
<th class="num">Bonds</th>
<th class="num">Bonds Book Value</th>
}
<th class="num">FD</th>
<th class="num">FD Principal</th>
<th class="num">Total Amount</th>
<th class="num">Share</th>
</tr>
</thead>
<tbody>
@for (b of r.buckets; track b.code) {
<tr class="report-pick" [class.report-pick--on]="bucket === b.code" (click)="pickBucket(b)">
<td>
<strong>{{ b.label }}</strong>
</td>
<td class="num">{{ b.govtCount }}</td>
<td class="num">₹{{ b.govtFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ b.govtBookValue | number: '1.2-2' }}</td>
@if (hasBonds) {
<td class="num">{{ b.bondCount }}</td>
<td class="num">₹{{ b.bondBookValue | number: '1.2-2' }}</td>
}
<td class="num">{{ b.fdCount }}</td>
<td class="num">₹{{ b.fdPrincipal | number: '1.2-2' }}</td>
<td class="num">₹{{ b.totalAmount | number: '1.2-2' }}</td>
<td class="num">{{ share(b) | number: '1.2-2' }}%</td>
</tr>
}
</tbody>
<tfoot>
<tr class="report-total">
<td>Total</td>
<td class="num">{{ r.total.govtCount }}</td>
<td class="num">₹{{ r.total.govtFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ r.total.govtBookValue | number: '1.2-2' }}</td>
@if (hasBonds) {
<td class="num">{{ r.total.bondCount }}</td>
<td class="num">₹{{ r.total.bondBookValue | number: '1.2-2' }}</td>
}
<td class="num">{{ r.total.fdCount }}</td>
<td class="num">₹{{ r.total.fdPrincipal | number: '1.2-2' }}</td>
<td class="num">₹{{ r.total.totalAmount | number: '1.2-2' }}</td>
<td class="num">{{ r.total.totalAmount > 0 ? '100.00%' : '—' }}</td>
</tr>
</tfoot>
</table>
</div>
<div class="pg-card">
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Bucket</span>
<select [(ngModel)]="bucket">
<option value="">All buckets</option>
@for (b of r.buckets; track b.code) {
<option [value]="b.code">{{ b.label }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Investment</span>
<select [(ngModel)]="instrument">
<option value="">All</option>
<option value="GOVT_SECURITY">Govt Security</option>
@if (hasBonds) {
<option value="BOND">Bond / Non-SLR</option>
}
<option value="FD">FD with banks</option>
</select>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Bucket</th>
<th>Investment</th>
<th>Deal No</th>
<th>Particulars</th>
<th>Category</th>
<th class="num">Rate</th>
<th>Maturity</th>
<th>Time Left</th>
<th class="num">Face Value / Principal</th>
<th class="num">Book Value / Principal</th>
</tr>
</thead>
<tbody>
@for (x of rows; track x.instrument + x.refId) {
<tr>
<td>{{ bucketLabel(x.bucket) }}</td>
<td>{{ instrumentLabel(x.instrument) }}</td>
<td>
<span class="pg-chip">{{ x.dealNo }}</span>
</td>
<td>{{ x.particulars }}</td>
<td>{{ x.category || '—' }}</td>
<td class="num">{{ x.rate !== null && x.rate !== undefined ? x.rate + '%' : '—' }}</td>
<td>{{ x.maturityDate }}</td>
<td>
@if (x.daysToMaturity < 0) {
<span class="inv-badge diary-badge--overdue">{{ timeLeft(x) }}</span>
} @else {
{{ timeLeft(x) }}
}
</td>
<td class="num">₹{{ x.faceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ x.bookValue | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="pg-empty">No investment held in this selection.</td>
</tr>
}
</tbody>
@if (rows.length) {
<tfoot>
<tr class="report-total">
<td colspan="8">Total ({{ rows.length }})</td>
<td class="num">₹{{ rowsTotal.faceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ rowsTotal.bookValue | number: '1.2-2' }}</td>
</tr>
</tfoot>
}
</table>
</div>
}
</div>
@@ -0,0 +1,235 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { todayDDMMYYYY } from '../investment-data';
import { exportWorkbook } from '../investment-export';
import {
InvestmentBucketDto,
InvestmentBucketReportDto,
InvestmentBucketRowDto,
InvestmentService,
apiErrorMessage
} from '../investment.service';
type InstrumentFilter = '' | InvestmentBucketRowDto['instrument'];
const INSTRUMENT_LABEL: Record<InvestmentBucketRowDto['instrument'], string> = {
GOVT_SECURITY: 'Govt Security',
BOND: 'Bond / Non-SLR',
FD: 'FD'
};
/**
* Bucket Wise Investment: Govt Securities, bonds and FDs held on a date, split by the time left to maturity — up to
* 1 year, over 1 to 3, over 3 to 5 and over 5 years. Clicking a bucket lists the holdings in it.
*/
@Component({
selector: 'mifosx-investment-bucket-report',
templateUrl: './investment-bucket-report.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss',
'../investment-diary/investment-diary.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class InvestmentBucketReportComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
asOnDate = todayDDMMYYYY();
bucket = '';
instrument: InstrumentFilter = '';
report: InvestmentBucketReportDto | null = null;
loading = false;
generating = false;
error = '';
ngOnInit(): void {
this.load();
}
load(): void {
if (!this.asOnDate) {
this.error = 'Enter the As on date.';
return;
}
this.loading = true;
this.error = '';
this.investmentService.getBucketReport(this.asOnDate).subscribe({
next: (r: InvestmentBucketReportDto) => {
this.report = r;
this.loading = false;
},
error: (err: unknown) => {
this.report = null;
this.loading = false;
this.error = apiErrorMessage(err);
}
});
}
/** Bonds / Non-SLR columns only when any are held. */
get hasBonds(): boolean {
return (this.report?.total.bondCount ?? 0) > 0;
}
get rows(): InvestmentBucketRowDto[] {
const all: InvestmentBucketRowDto[] = this.report?.rows ?? [];
return all.filter(
(r: InvestmentBucketRowDto) =>
(!this.bucket || r.bucket === this.bucket) && (!this.instrument || r.instrument === this.instrument)
);
}
get rowsTotal(): { faceValue: number; bookValue: number } {
return this.rows.reduce(
(t: { faceValue: number; bookValue: number }, r: InvestmentBucketRowDto) => ({
faceValue: t.faceValue + r.faceValue,
bookValue: t.bookValue + r.bookValue
}),
{ faceValue: 0, bookValue: 0 }
);
}
pickBucket(b: InvestmentBucketDto): void {
this.bucket = this.bucket === b.code ? '' : b.code;
}
bucketLabel(code: string): string {
return this.report?.buckets.find((b: InvestmentBucketDto) => b.code === code)?.label ?? code;
}
instrumentLabel(i: InvestmentBucketRowDto['instrument']): string {
return INSTRUMENT_LABEL[i];
}
/** Share of the bucket in the total amount, in %. */
share(b: InvestmentBucketDto): number {
const total = this.report?.total.totalAmount ?? 0;
return total > 0 ? (b.totalAmount / total) * 100 : 0;
}
/** Time left to maturity as years and months, or how long it is overdue. */
timeLeft(r: InvestmentBucketRowDto): string {
if (r.daysToMaturity < 0) {
return `Overdue ${-r.daysToMaturity} days`;
}
const months = Math.floor(r.daysToMaturity / 30.4375);
const years = Math.floor(months / 12);
if (years === 0 && months === 0) {
return `${r.daysToMaturity} days`;
}
return years > 0 ? `${years}y ${months % 12}m` : `${months}m`;
}
async exportExcel(): Promise<void> {
const report = this.report;
if (!report) {
return;
}
this.generating = true;
try {
const bucketRow = (b: InvestmentBucketDto): (string | number | null)[] => [
b.label,
b.govtCount,
b.govtFaceValue,
b.govtBookValue,
...(this.hasBonds ? [
b.bondCount,
b.bondFaceValue,
b.bondBookValue
] : []),
b.fdCount,
b.fdPrincipal,
b.totalAmount,
Number(this.share(b).toFixed(2))
];
await exportWorkbook(
[
{
sheetName: 'Bucket Summary',
title: 'Bucket Wise Investment',
subtitle: `As on ${report.asOnDate} · by time left to maturity`,
headers: [
'Bucket',
'Govt Sec (No.)',
'Govt Sec Face Value',
'Govt Sec Book Value',
...(this.hasBonds ? [
'Bonds (No.)',
'Bonds Face Value',
'Bonds Book Value'
] : []),
'FD (No.)',
'FD Principal',
'Total Amount',
'Share %'
],
rows: [
...report.buckets.map(bucketRow),
bucketRow(report.total)
]
},
{
sheetName: 'Holdings',
title: 'Bucket Wise Investment — Holdings',
subtitle: `As on ${report.asOnDate}`,
headers: [
'Bucket',
'Investment',
'Deal No',
'Particulars',
'Category',
'Rate %',
'Maturity',
'Time Left',
'Face Value / Principal',
'Book Value / Principal'
],
widths: [
22,
16,
16,
40,
10,
8,
12,
16,
20,
20
],
rows: report.rows.map((r: InvestmentBucketRowDto) => [
this.bucketLabel(r.bucket),
this.instrumentLabel(r.instrument),
r.dealNo,
r.particulars,
r.category ?? '',
r.rate ?? null,
r.maturityDate,
this.timeLeft(r),
r.faceValue,
r.bookValue
])
}
],
`Bucket-Wise-Investment-${report.asOnDate}`
);
} finally {
this.generating = false;
}
}
}
+77 -1
View File
@@ -1589,8 +1589,80 @@ export interface ScriptMaster {
phone?: string;
fax?: string;
corpCode?: string;
/** Credit rating — long-term for bonds, short-term for CP / CD (Non-SLR and Bond scripts). */
rating?: string;
ratingAgency?: string;
/** Listed on an exchange or proposed to be listed; unlisted ones have their own RBI limit. */
listed?: boolean;
}
/** Credit ratings on the Script Master, best first: long-term for bonds, short-term for CP / CD. */
export const RATING_OPTIONS: Array<{ group: string; values: string[] }> = [
{
group: 'Long-term (bonds)',
values: [
'AAA',
'AA+',
'AA',
'AA-',
'A+',
'A',
'A-',
'BBB+',
'BBB',
'BBB-',
'BB+',
'BB',
'BB-',
'B+',
'B',
'B-',
'C',
'D'
]
},
{
group: 'Short-term (CP / CD)',
values: [
'A1+',
'A1',
'A2+',
'A2',
'A3+',
'A3',
'A4+',
'A4'
]
},
{ group: 'None', values: ['UNRATED'] }
];
/** RBI: bonds rated "A" or higher, CP / CD rated "A2" or higher. */
export const ELIGIBLE_RATINGS: string[] = [
'AAA',
'AA+',
'AA',
'AA-',
'A+',
'A',
'A-',
'A1+',
'A1',
'A2+',
'A2'
];
/** Credit rating agencies registered with SEBI. */
export const RATING_AGENCY_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'CRISIL', label: 'CRISIL' },
{ value: 'ICRA', label: 'ICRA' },
{ value: 'CARE', label: 'CARE' },
{ value: 'INDIA_RATINGS', label: 'India Ratings' },
{ value: 'ACUITE', label: 'Acuite' },
{ value: 'BRICKWORK', label: 'Brickwork' },
{ value: 'INFOMERICS', label: 'Infomerics' }
];
/** Period between two DD-MM-YYYY dates as years / months / days, as shown on the Script Master. */
export function periodBetween(fromStr?: string, toStr?: string): { years: number; months: number; days: number } {
const none = { years: 0, months: 0, days: 0 };
@@ -2052,7 +2124,11 @@ export const SUBTYPE_MASTER: SubTypeMaster[] = [
{ subTypeCode: '4', subTypeName: 'EQUITY', instrumentType: 'SHARE' },
{ subTypeCode: '5', subTypeName: 'CO-OP SHARE', instrumentType: 'SHARE' },
{ subTypeCode: '6', subTypeName: 'DEBT - OVERNIGHT', instrumentType: 'MUTUAL_FUND' },
{ subTypeCode: '7', subTypeName: 'DEBT - LIQUID', instrumentType: 'MUTUAL_FUND' }
{ subTypeCode: '7', subTypeName: 'DEBT - LIQUID', instrumentType: 'MUTUAL_FUND' },
{ subTypeCode: '8', subTypeName: 'DEBT - MONEY MARKET', instrumentType: 'MUTUAL_FUND' },
{ subTypeCode: '9', subTypeName: 'DEBT - BANKING & PSU', instrumentType: 'MUTUAL_FUND' },
{ subTypeCode: '10', subTypeName: 'DEBT - CORPORATE BOND', instrumentType: 'MUTUAL_FUND' },
{ subTypeCode: '11', subTypeName: 'DEBT - GILT', instrumentType: 'MUTUAL_FUND' }
];
export interface ControlParameter {
@@ -87,3 +87,11 @@
font-weight: 700;
border-top: 2px solid #cbd5e1;
}
.report-pick {
cursor: pointer;
&--on td {
background: #eff6ff;
}
}
@@ -323,6 +323,52 @@
><span>Period (Days)</span><input type="text" [value]="scriptPeriod.days" readonly
/></label>
</div>
@if (scriptIsRated) {
<div class="pos-section-label">Credit Rating (RBI: bonds "A" or higher, CP / CD "A2" or higher)</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>* Rating</span>
<select [(ngModel)]="scriptForm.rating">
<option [ngValue]="undefined">-- Select --</option>
@for (g of ratingOptions; track g.group) {
<optgroup [label]="g.group">
@for (r of g.values; track r) {
<option [value]="r">{{ r }}</option>
}
</optgroup>
}
</select>
</label>
<label class="inv-form-field">
<span>Rating Agency</span>
<select [(ngModel)]="scriptForm.ratingAgency">
<option [ngValue]="undefined">-- Select --</option>
@for (a of ratingAgencyOptions; track a.value) {
<option [value]="a.value">{{ a.label }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Listing</span>
<select
[ngModel]="scriptForm.listed === false ? 'UNLISTED' : 'LISTED'"
(ngModelChange)="scriptForm.listed = $event !== 'UNLISTED'"
>
<option value="LISTED">Listed / proposed to be listed</option>
<option value="UNLISTED">Unlisted</option>
</select>
</label>
</div>
@if (scriptForm.rating) {
<p class="inv-hint" [class.inv-hint--bad]="!scriptRatingEligible">
{{
scriptRatingEligible
? 'Rating meets the RBI minimum; this security can be bought.'
: 'Below the RBI minimum rating; a purchase of this security will be blocked.'
}}
</p>
}
}
<div class="pos-section-label">Company Details (Bond / Mutual Fund)</div>
<div class="inv-form-grid">
<label class="inv-form-field"
@@ -381,6 +427,7 @@
<th class="num">Int Rate %</th>
<th>Issue Date</th>
<th>Maturity</th>
<th>Rating</th>
<th>Status</th>
</tr>
</thead>
@@ -398,6 +445,7 @@
<td class="num">{{ s.couponRate ?? '—' }}</td>
<td>{{ s.issueDate || '—' }}</td>
<td>{{ s.maturityDate || '—' }}</td>
<td>{{ s.rating ? s.rating + (s.listed === false ? ' · Unlisted' : '') : '—' }}</td>
<td>{{ s.active === false ? 'INACTIVE' : 'ACTIVE' }}</td>
</tr>
}
@@ -32,6 +32,9 @@ import {
BANK_TYPE_OPTIONS,
SLR_TYPE_OPTIONS,
INVESTMENT_GL_LOOKUP,
ELIGIBLE_RATINGS,
RATING_AGENCY_OPTIONS,
RATING_OPTIONS,
periodBetween
} from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
@@ -124,6 +127,8 @@ export class InvestmentMastersComponent implements OnInit {
saving = false;
readonly bankTypeOptions = BANK_TYPE_OPTIONS;
readonly slrTypeOptions = SLR_TYPE_OPTIONS;
readonly ratingOptions = RATING_OPTIONS;
readonly ratingAgencyOptions = RATING_AGENCY_OPTIONS;
/** Investment GLs (under the 100700 INVESTMENTS head) a party or script can be mapped to — not loans, banks or test GLs. */
investmentGlOptions: InvestmentGlAccount[] = [];
/** Income GLs under 400400 INCOME FROM INVESTMENTS, for an FD bank's interest. */
@@ -295,6 +300,19 @@ export class InvestmentMastersComponent implements OnInit {
].includes(this.scriptForm.instrumentType ?? '');
}
/** Corporate bonds, CPs and CDs carry a credit rating and a listed / unlisted flag (RBI non-SLR rules). */
get scriptIsRated(): boolean {
return [
'NON_SLR',
'BOND'
].includes(this.scriptForm.instrumentType ?? '');
}
/** Whether the rating entered meets the RBI minimum: "A" for bonds, "A2" for CP / CD. */
get scriptRatingEligible(): boolean {
return !!this.scriptForm.rating && ELIGIBLE_RATINGS.includes(this.scriptForm.rating);
}
/** Preview of the code the backend will assign — a type letter + running number, e.g. G00001. */
get nextScriptCode(): string {
const prefixByType: Record<InstrumentType, string> = {
@@ -332,6 +350,10 @@ export class InvestmentMastersComponent implements OnInit {
this.toast('Enter ISIN, Int Rate, Issue Date and Maturity Date for this security.');
return;
}
if (this.scriptIsRated && !f.rating) {
this.toast('Enter the credit rating of this bond / CP / CD.');
return;
}
const body = {
bondType: f.instrumentType,
slrType: f.slrType ?? '',
@@ -350,6 +372,9 @@ export class InvestmentMastersComponent implements OnInit {
phone: f.phone ?? '',
fax: f.fax ?? '',
corpCode: f.corpCode ?? '',
rating: this.scriptIsRated ? (f.rating ?? '') : '',
ratingAgency: this.scriptIsRated ? (f.ratingAgency ?? '') : '',
listed: !this.scriptIsRated || f.listed !== false,
active: f.active !== false,
dateFormat: 'dd-MM-yyyy',
locale: 'en'
@@ -19,6 +19,7 @@ import { InvestmentReportsComponent } from './investment-reports/investment-repo
import { InvestmentInterestComponent } from './investment-interest/investment-interest.component';
import { InvestmentDiaryComponent } from './investment-diary/investment-diary.component';
import { InvestmentInterestReportComponent } from './investment-interest-report/investment-interest-report.component';
import { InvestmentBucketReportComponent } from './investment-bucket-report/investment-bucket-report.component';
const routes: Routes = [
Route.withShell([
@@ -66,6 +67,11 @@ const routes: Routes = [
component: InvestmentInterestReportComponent,
data: { title: 'Interest Received & Receivable', breadcrumb: 'Interest Received & Receivable' }
},
{
path: 'bucket-report',
component: InvestmentBucketReportComponent,
data: { title: 'Bucket Wise Investment', breadcrumb: 'Bucket Wise Investment' }
},
{
path: 'reports',
component: InvestmentReportsComponent,
+11
View File
@@ -260,3 +260,14 @@
transform: translateX(-50%) translateY(0);
}
}
.inv-hint {
margin: 8px 0;
padding: 0 16px;
font-size: 13px;
color: #15803d;
&--bad {
color: #b91c1c;
}
}
+75
View File
@@ -169,6 +169,44 @@ export interface InvestmentInterestReportDto {
rows: InvestmentInterestReportRowDto[];
}
/** One maturity bucket of the Bucket Wise Investment report, or its total. */
export interface InvestmentBucketDto {
code: string;
label: string;
govtCount: number;
govtFaceValue: number;
govtBookValue: number;
bondCount: number;
bondFaceValue: number;
bondBookValue: number;
fdCount: number;
fdPrincipal: number;
totalAmount: number;
}
/** One Govt Security, bond or FD held on the as-on date, with the bucket it falls in. */
export interface InvestmentBucketRowDto {
bucket: string;
instrument: 'GOVT_SECURITY' | 'BOND' | 'FD';
refId: number;
dealNo: string;
particulars: string;
category?: string;
rate?: number;
maturityDate: string;
daysToMaturity: number;
faceValue: number;
bookValue: number;
}
/** Holdings on a date split by the time left to maturity. */
export interface InvestmentBucketReportDto {
asOnDate: string;
buckets: InvestmentBucketDto[];
total: InvestmentBucketDto;
rows: InvestmentBucketRowDto[];
}
/** One dated event in the Due Date Diary. */
export interface InvestmentDiaryRowDto {
date: string;
@@ -250,9 +288,29 @@ export interface InvestmentScriptDto {
phone?: string;
fax?: string;
corpCode?: string;
rating?: string;
ratingAgency?: string;
listed: boolean;
active: boolean;
}
/** Where a non-SLR purchase stands against the RBI limits, from /investment/purchases/non-slr-limit. */
export interface InvestmentNonSlrLimitDto {
asOnDate: string;
totalDeposits?: number;
nonSlrLimit?: number;
nonSlrOutstanding: number;
nonSlrAfter: number;
nonSlrBase: number;
unlistedLimit: number;
unlistedOutstanding: number;
unlistedAfter: number;
newAmount: number;
listed: boolean;
withinLimits: boolean;
message: string;
}
/** Purchase entry (holding) as returned by /investment/purchases (dates are DD-MM-YYYY). */
export interface InvestmentPurchaseDto {
id: number;
@@ -468,6 +526,15 @@ export class InvestmentService {
);
}
/** RBI non-SLR limits for a purchase of `amount` (book value) in a script on a deal date (DD-MM-YYYY). */
getNonSlrLimit(scriptId: number | null, dealDate: string, amount: number): Observable<InvestmentNonSlrLimitDto> {
const params: Record<string, string> = { dealDate, amount: String(amount || 0) };
if (scriptId) {
params['scriptId'] = String(scriptId);
}
return this.http.get<InvestmentNonSlrLimitDto>('/investment/purchases/non-slr-limit', { params });
}
createPurchase(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/purchases', body);
}
@@ -532,6 +599,11 @@ export class InvestmentService {
return this.http.get<InvestmentInterestReportDto>('/investment/interest-report', { params: { fromDate, toDate } });
}
/** Bucket Wise Investment: holdings on a date (DD-MM-YYYY) split by the time left to maturity. */
getBucketReport(asOnDate: string): Observable<InvestmentBucketReportDto> {
return this.http.get<InvestmentBucketReportDto>('/investment/bucket-report', { params: { asOnDate } });
}
/** Due Date Diary: dated events of all active investments between two dates (DD-MM-YYYY). */
getDiary(fromDate: string, toDate: string): Observable<InvestmentDiaryDto> {
return this.http.get<InvestmentDiaryDto>('/investment/diary', { params: { fromDate, toDate } });
@@ -643,6 +715,9 @@ export function toScriptMaster(s: InvestmentScriptDto): ScriptMaster {
phone: s.phone,
fax: s.fax,
corpCode: s.corpCode,
rating: s.rating,
ratingAgency: s.ratingAgency,
listed: s.listed,
active: s.active
};
}