feat(investment): non-SLR bonds and mutual funds, rate upload, UAT fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 11:14:41 +05:30
co-authored by Claude Opus 5.5
parent 8e98077158
commit 13d04353e1
13 changed files with 486 additions and 111 deletions
@@ -160,7 +160,7 @@
<td class="num">₹{{ carryingValue(h) | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.purchaseDate }}</td>
<td>{{ h.maturityDate }}</td>
<td>{{ h.instrumentType === 'MUTUAL_FUND' ? '—' : h.maturityDate }}</td>
<td class="num col-in">₹{{ accrued(h) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
<td>
@@ -203,7 +203,7 @@
<div class="inv-form-grid">
<label class="inv-form-field">
<span>* Bond Type</span>
<select [(ngModel)]="purchaseForm.bondType">
<select [ngModel]="purchaseForm.bondType" (ngModelChange)="setBondType($event)">
@for (o of bondTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
@@ -240,7 +240,7 @@
/></label>
<label class="inv-form-field">
<span>* Settlement Type</span>
<select [(ngModel)]="purchaseForm.settlementType">
<select [(ngModel)]="purchaseForm.settlementType" (ngModelChange)="onSettlementTypeChange()">
<option value="T+0">T+0</option>
<option value="T+1">T+1</option>
<option value="T+2">T+2</option>
@@ -264,14 +264,14 @@
}
<label class="inv-form-field">
<span>* Category</span>
<select [(ngModel)]="purchaseForm.category">
<select [(ngModel)]="purchaseForm.category" [disabled]="isNonSlrPurchase">
<option value="">-- Select --</option>
@for (c of categoryOptions; track c) {
@for (c of purchaseCategoryOptions; track c) {
<option [value]="c">{{ c }}</option>
}
</select>
</label>
@if (!isMutualFund) {
@if (!isNonSlrPurchase) {
<label class="inv-form-field"
><span>* Last Interest Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
@@ -373,7 +373,8 @@
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="'₹' + (purchaseBookValue | number: '1.2-2')" readonly
/></label>
@if (!isMutualFund) {
<!-- Non-SLR has no premium split, broken-period interest or yield: it is booked at the price paid. -->
@if (!isNonSlrPurchase) {
<label class="inv-form-field"
><span>Premium (+) / Discount (−)</span
><input type="text" [value]="'₹' + (purchasePremiumDiscount | number: '1.2-2')" readonly
@@ -394,7 +395,7 @@
/></label>
</div>
@if (!isMutualFund && purchaseCouponDates) {
@if (!isNonSlrPurchase && purchaseCouponDates) {
<div class="pos-section-label">Interest Cycle</div>
<div class="inv-form-grid">
<label class="inv-form-field"
@@ -753,7 +754,13 @@
/></label>
<label class="inv-form-field">
<span>{{
q.profitLoss >= 0 ? 'Discount realised (profit 400405)' : 'Premium written off (expense 500604)'
isNonSlrSale(q)
? q.profitLoss >= 0
? 'Profit on redemption (income 400402)'
: 'Loss on redemption (expense 500605)'
: q.profitLoss >= 0
? 'Discount realised (profit 400405)'
: 'Premium written off (expense 500604)'
}}</span>
<input
type="text"
@@ -786,9 +793,13 @@
· Cr {{ q.premiumGlCode }} Premium ₹{{ q.premiumCleared | number: '1.2-2' }}
}
@if (q.profitLoss > 0) {
· Cr 400405 Profit on Redemption ₹{{ q.profitLoss | number: '1.2-2' }}
· Cr {{ isNonSlrSale(q) ? '400402 Interest on Bonds' : '400405 Profit on Redemption' }} ₹{{
q.profitLoss | number: '1.2-2'
}}
} @else if (q.profitLoss < 0) {
· Dr 500604 Premium Amortisation ₹{{ -q.profitLoss | number: '1.2-2' }}
· Dr {{ isNonSlrSale(q) ? '500605 Loss on Investments' : '500604 Premium Amortisation' }} ₹{{
-q.profitLoss | number: '1.2-2'
}}
}
</p>
}
@@ -1074,7 +1085,7 @@
><input type="number" min="0" [(ngModel)]="sale.units" (change)="refreshSaleQuote()"
/></label>
<label class="inv-form-field"
><span>* Sale Rate (per ₹100)</span
><span>* {{ saleIsMutualFund ? 'NAV per unit' : 'Sale Rate (per ₹100)' }}</span
><input type="number" min="0" step="0.0001" [(ngModel)]="sale.saleRate" (change)="refreshSaleQuote()"
/></label>
<label class="inv-form-field"
@@ -1148,7 +1159,9 @@
· Cr {{ q.premiumGlCode }} Premium ₹{{ q.premiumCleared | number: '1.2-2' }}
}
@if (q.profitLoss > 0) {
· Cr 400405 Profit ₹{{ q.profitLoss | number: '1.2-2' }}
· Cr {{ isNonSlrSale(q) ? '400402 Interest on Bonds' : '400405 Profit' }} ₹{{
q.profitLoss | number: '1.2-2'
}}
} @else if (q.profitLoss < 0) {
· Dr 500605 Loss ₹{{ -q.profitLoss | number: '1.2-2' }}
}
@@ -1471,7 +1484,7 @@
<span>* Deal No</span>
<select [(ngModel)]="goiPurchaseId" (ngModelChange)="onGoiDealChange()">
<option [ngValue]="null">-- Select --</option>
@for (h of saleableHoldings; track h.certNo) {
@for (h of goiTransferHoldings; track h.certNo) {
<option [ngValue]="h.id">{{ h.dealNo }} — {{ h.scriptCode }} {{ h.scriptName }}</option>
}
</select>
@@ -73,6 +73,7 @@ import {
InvestmentSecurityTransferDto,
InvestmentNonSlrLimitDto,
InvestmentService,
NON_SLR_TYPES,
apiErrorMessage,
toHolding,
toInterestTxn,
@@ -200,6 +201,8 @@ export class BondInvestmentComponent implements OnInit {
const tab = this.route.snapshot.queryParamMap.get('tab');
if (tab === 'interest' || tab === 'redemption' || tab === 'amortize') {
this.activeTab = tab;
// The Due Date Diary also names the deal (?purchase=id): it is opened once the holdings have loaded.
this.deepLinkPurchaseId = Number(this.route.snapshot.queryParamMap.get('purchase')) || null;
}
this.investmentService.syncSharedMasters().subscribe({
error: (err) => this.toast(`Could not load Script / Party masters: ${apiErrorMessage(err)}`)
@@ -230,11 +233,34 @@ export class BondInvestmentComponent implements OnInit {
this.purchaseForm = this.blankPurchaseForm();
this.loadNonSlrLimit();
this.loadInterestLog();
this.openDeepLinkedDeal();
},
error: (err) => this.toast(`Could not load purchases: ${apiErrorMessage(err)}`)
});
}
private deepLinkPurchaseId: number | null = null;
/** Selects the deal a Due Date Diary link named, on its tab, and loads it. */
private openDeepLinkedDeal(): void {
const h = this.holdings.find((x) => x.id === this.deepLinkPurchaseId);
this.deepLinkPurchaseId = null;
if (!h) {
return;
}
this.setBondType(h.instrumentType as BondType);
if (this.activeTab === 'interest') {
this.intPurchaseId = h.id ?? null;
this.retrieveInterestSchedule();
} else if (this.activeTab === 'redemption') {
this.redeemPurchaseId = h.id ?? null;
this.onRedeemDealChange();
} else if (this.activeTab === 'amortize') {
this.amortizePurchaseId = h.id ?? null;
this.onAmortizeDealChange();
}
}
/** Posted coupons / provisions join the purchases in the transaction log, latest date first. */
private loadInterestLog(): void {
forkJoin({
@@ -350,12 +376,30 @@ export class BondInvestmentComponent implements OnInit {
}
setBondType(type: BondType): void {
const typeChanged = this.purchaseForm.bondType !== type;
this.bondType = type;
this.purchaseForm.bondType = type;
this.purchaseForm.subType = this.defaultSubType(type);
if (this.isNonSlrPurchase) {
this.purchaseForm.category = this.categoryOptions.find((c) => c.startsWith('AFS')) ?? '';
} else if (typeChanged && this.purchaseForm.category.startsWith('AFS')) {
this.purchaseForm.category = '';
}
if (typeChanged) {
// The script, its ISIN, rating and market rate belong to the old bond type.
this.purchaseForm.scriptCode = '';
this.purchaseForm.isin = '';
this.purchaseForm.bondRating = '';
this.purchaseMarketRate = undefined;
}
this.loadNonSlrLimit();
}
/** Non-SLR (bonds, mutual funds) can be bought and sold at any time, so it is always AFS (Jitendra Sir). */
get purchaseCategoryOptions(): string[] {
return this.isNonSlrPurchase ? this.categoryOptions.filter((c) => c.startsWith('AFS')) : this.categoryOptions;
}
private defaultSubType(type: BondType): string {
return type === 'GOVT_SECURITY'
? 'SLR'
@@ -372,11 +416,7 @@ export class BondInvestmentComponent implements OnInit {
/** 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);
return NON_SLR_TYPES.includes(this.purchaseForm.bondType);
}
loadNonSlrLimit(): void {
@@ -530,7 +570,8 @@ export class BondInvestmentComponent implements OnInit {
fromDist: '',
toDist: '',
redumMatDt: '',
category: '',
// Non-SLR (bond, mutual fund) is always AFS (Jitendra Sir).
category: NON_SLR_TYPES.includes(type) ? 'AFS (Available For Sale)' : '',
intRate: null,
intType: 'HALF_YEARLY',
intDate: this.asOnDate,
@@ -640,13 +681,19 @@ export class BondInvestmentComponent implements OnInit {
if (!f.intRate || !this.purchaseMaturityDate) {
return 0;
}
return approxYtm(f.intRate, f.purchaseRate || 100, yearsBetween(f.issueDate, this.purchaseMaturityDate));
// Over the life still to run from settlement, not the security's whole life.
return approxYtm(
f.intRate,
f.purchaseRate || 100,
yearsBetween(this.purchaseSettlementDate, this.purchaseMaturityDate)
);
}
/** Broken-period interest owed to the seller from the last coupon date up to the settlement date, 30/360. */
get purchaseAccruedInterest(): number {
const f = this.purchaseForm;
if (!f.intRate || !f.lastIntDt || !f.dealDate) {
// Non-SLR has no interest scheme (Jitendra Sir): no broken-period interest.
if (this.isNonSlrPurchase || !f.intRate || !f.lastIntDt || !f.dealDate) {
return 0;
}
return Math.round((this.purchaseBaseAmount * f.intRate * this.purchaseAccruedDays) / 360) / 100;
@@ -661,7 +708,8 @@ export class BondInvestmentComponent implements OnInit {
/** Premium (+) or discount (−) paid over face value. */
get purchasePremiumDiscount(): number {
const f = this.purchaseForm;
if (this.isMutualFund || !f.faceValue || !f.purchaseRate) {
// Non-SLR is booked at the price paid; no premium is split out or amortised.
if (this.isNonSlrPurchase || !f.faceValue || !f.purchaseRate) {
return 0;
}
return Math.round(f.faceValue * (f.purchaseRate - 100)) / 100;
@@ -682,11 +730,23 @@ export class BondInvestmentComponent implements OnInit {
private suggestLastIntDate(): void {
const f = this.purchaseForm;
const s = this.purchaseScript;
if (!this.isMutualFund && s?.maturityDate && f.dealDate) {
f.lastIntDt = lastHalfYearlyCouponDate(s.maturityDate, f.dealDate) || f.lastIntDt;
// The last coupon on or before SETTLEMENT (one falling between deal and settlement goes to the seller), or the
// issue date when the security has not paid its first coupon yet — the same date the backend insists on.
const settlement = this.purchaseSettlementDate;
if (!this.isNonSlrPurchase && s?.maturityDate && settlement) {
let last = lastHalfYearlyCouponDate(s.maturityDate, settlement);
if (last && s.issueDate && parseDDMMYYYY(last) < parseDDMMYYYY(s.issueDate)) {
last = s.issueDate;
}
f.lastIntDt = last || f.lastIntDt;
}
}
/** A different T+n moves the settlement date, and with it the last coupon the deal starts from. */
onSettlementTypeChange(): void {
this.suggestLastIntDate();
}
/** Settlement Amt of Purchase — book value, accrued interest payable to the seller and the charges. */
get purchaseSettlementAmount(): number {
return this.purchaseBookValue + this.purchaseAccruedInterest + this.purchaseTotalCharges;
@@ -817,7 +877,8 @@ export class BondInvestmentComponent implements OnInit {
/** Parties offered for a new deal — inactive parties are hidden. */
get activePartyOptions(): typeof PARTY_MASTER {
return this.partyOptions.filter((p) => p.status !== 'INACTIVE');
// An FD bank is where deposits are placed, not a dealer securities are bought from or sold to.
return this.partyOptions.filter((p) => p.status !== 'INACTIVE' && p.bondType !== 'FD');
}
get purchaseInvestmentGlCode(): string {
@@ -898,7 +959,8 @@ export class BondInvestmentComponent implements OnInit {
} else {
if (!f.faceValue) missing.push('Face Value');
if (!f.purchaseRate) missing.push('Purchase Rate');
if (!f.lastIntDt) missing.push('Last Interest Date');
// Non-SLR has no interest scheme, so no last interest date (the field is hidden).
if (!this.isNonSlrPurchase && !f.lastIntDt) missing.push('Last Interest Date');
}
if (!f.category) missing.push('Category');
if (!f.crToAc) missing.push('CR. To A/c');
@@ -926,7 +988,11 @@ export class BondInvestmentComponent implements OnInit {
settlementType: f.settlementType,
...(this.isMutualFund
? { considerAmt: f.considerAmt, nav: f.nav }
: { faceValue: f.faceValue, purchaseRate: f.purchaseRate, lastIntDate: f.lastIntDt }),
: {
faceValue: f.faceValue,
purchaseRate: f.purchaseRate,
...(this.isNonSlrPurchase ? {} : { lastIntDate: f.lastIntDt })
}),
stampDuty: f.stampDuty ?? null,
brokerage: f.brokerage ?? null,
otherCharges: f.otherCharges ?? null,
@@ -1095,14 +1161,24 @@ export class BondInvestmentComponent implements OnInit {
redeeming = false;
/** Settled holdings of this type still held — the ones that can be sold or redeemed. */
/** A mutual fund is sold / redeemed at its NAV per unit, not at a price per ₹100. */
get saleIsMutualFund(): boolean {
return this.holdings.find((h) => h.id === this.sale.purchaseId)?.instrumentType === 'MUTUAL_FUND';
}
get saleableHoldings(): InvestmentHolding[] {
return this.activeHoldingsForType.filter((h) => !!h.id);
}
/** Redemption is at maturity, so only holdings that have reached it are offered. */
/**
* Redemption is at maturity, so only holdings that have reached it are offered. A mutual fund has no maturity: it
* is redeemed at its NAV through the Sell entry.
*/
get redeemableHoldings(): InvestmentHolding[] {
const today = parseDDMMYYYY(this.asOnDate);
return this.saleableHoldings.filter((h) => !!h.maturityDate && parseDDMMYYYY(h.maturityDate) <= today);
return this.saleableHoldings.filter(
(h) => h.instrumentType !== 'MUTUAL_FUND' && !!h.maturityDate && parseDDMMYYYY(h.maturityDate) <= today
);
}
/** Shows what the redemption will post, or why it cannot be done yet (before maturity, coupon not posted). */
@@ -1158,6 +1234,7 @@ export class BondInvestmentComponent implements OnInit {
(h) =>
h.id != null &&
h.status === 'ACTIVE' &&
h.instrumentType === 'GOVT_SECURITY' &&
(!this.intType || h.instrumentType === this.intType) &&
(!this.intScript || h.scriptCode === this.intScript)
);
@@ -1257,7 +1334,7 @@ export class BondInvestmentComponent implements OnInit {
/** Total of the lines falling due up to the as-on date. */
get intDueTotal(): number {
return (this.intSchedule?.rows ?? []).filter((r) => r.due).reduce((sum, r) => sum + r.amount, 0);
return (this.intSchedule?.rows ?? []).filter((r) => r.due && !r.posted).reduce((sum, r) => sum + r.amount, 0);
}
// -------------------- Sell Entry --------------------
@@ -1310,8 +1387,12 @@ export class BondInvestmentComponent implements OnInit {
}
/** Price, interest from the buyer, cost and profit / loss worked out by the backend, as Settle will post them. */
/** Each quote request is numbered so a slower, older response cannot overwrite the latest one. */
private saleQuoteSeq = 0;
refreshSaleQuote(): void {
const f = this.sale;
const seq = ++this.saleQuoteSeq;
this.saleQuote = null;
this.saleQuoteError = '';
if (!f.purchaseId || !f.units || !f.saleRate || !f.dealDate) {
@@ -1329,8 +1410,16 @@ export class BondInvestmentComponent implements OnInit {
...(bankGlAccountId ? { bankGlAccountId } : {})
})
.subscribe({
next: (q) => (this.saleQuote = q),
error: (err) => (this.saleQuoteError = apiErrorMessage(err))
next: (q) => {
if (seq === this.saleQuoteSeq) {
this.saleQuote = q;
}
},
error: (err) => {
if (seq === this.saleQuoteSeq) {
this.saleQuoteError = apiErrorMessage(err);
}
}
});
}
@@ -1434,6 +1523,11 @@ export class BondInvestmentComponent implements OnInit {
return !!q.premiumCleared && q.premiumCleared > 0 && !!q.premiumGlCode && q.premiumGlCode !== q.investmentGlCode;
}
/** Non-SLR gain on sale / redemption is its income, 400402; a shortfall is a loss, 500605 (Jitendra Sir). */
isNonSlrSale(q: InvestmentSaleDto): boolean {
return NON_SLR_TYPES.includes(q.bondType);
}
/** Book value of a holding as it stands: cost less what sales and write-offs have taken off. */
carryingValue(h: InvestmentHolding): number {
const taken = this.sales
@@ -1573,6 +1667,11 @@ export class BondInvestmentComponent implements OnInit {
goiRemarks = '';
goiSaving = false;
/** Only Govt Security holdings can be transferred (the backend refuses bonds, which would skip the non-SLR checks). */
get goiTransferHoldings(): InvestmentHolding[] {
return this.saleableHoldings.filter((h) => h.instrumentType === 'GOVT_SECURITY');
}
get goiHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.id === this.goiPurchaseId);
}
@@ -1763,6 +1862,10 @@ export class BondInvestmentComponent implements OnInit {
* Coupons fall every six months from the purchase's last interest date; a coupon received resets it to zero.
*/
accrued(h: InvestmentHolding): number {
// Non-SLR has no interest scheme: nothing accrues.
if (NON_SLR_TYPES.includes(h.instrumentType)) {
return 0;
}
if (!h.lastIntDate || h.status !== 'ACTIVE') {
return h.lastIntDate ? 0 : accruedInterest(h, this.asOnDate);
}
@@ -1774,8 +1877,11 @@ export class BondInvestmentComponent implements OnInit {
return Math.round((h.faceValue * h.interestRate * days360(lastCoupon, asOn)) / 360) / 100;
}
/** A fund or share has no maturity: it comes back at the book value still held, as Reports and the Dashboard show. */
maturity(h: InvestmentHolding): number {
return maturityAmount(h);
return h.instrumentType === 'MUTUAL_FUND' || h.instrumentType === 'SHARE'
? this.carryingValue(h)
: maturityAmount(h);
}
daysLeft(h: InvestmentHolding): number {
@@ -1833,7 +1939,7 @@ export class BondInvestmentComponent implements OnInit {
h.faceValue,
h.interestRate,
h.purchaseDate,
h.maturityDate,
h.instrumentType === 'MUTUAL_FUND' ? '' : h.maturityDate,
this.accrued(h),
this.maturity(h),
h.status