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
@@ -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();
}
/**