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
+2 -2
View File
@@ -56,7 +56,7 @@
@if (pendingInvestmentInterest.length) { @if (pendingInvestmentInterest.length) {
<div class="iia-row"> <div class="iia-row">
<span class="iia-summary" <span class="iia-summary"
>GOI / Bonds: {{ pendingInvestmentInterest.length }} interest voucher(s){{ >GOI / Bonds: {{ pendingInvestmentInterest.length }} holding(s) with interest pending{{
overdueNote(pendingInvestmentInterest) overdueNote(pendingInvestmentInterest)
}}</span }}</span
> >
@@ -82,7 +82,7 @@
@if (pendingFd.length) { @if (pendingFd.length) {
<div class="iia-row"> <div class="iia-row">
<span class="iia-summary" <span class="iia-summary"
>FD with banks: {{ pendingFd.length }} entry(ies){{ overdueNote(pendingFd) }}</span >FD with banks: {{ pendingFd.length }} FD(s) with entries pending{{ overdueNote(pendingFd) }}</span
> >
<button type="button" class="iia-toggle" (click)="showFdDetails = !showFdDetails"> <button type="button" class="iia-toggle" (click)="showFdDetails = !showFdDetails">
{{ showFdDetails ? 'Hide' : 'Details' }} {{ showFdDetails ? 'Hide' : 'Details' }}
@@ -160,7 +160,7 @@
<td class="num">₹{{ carryingValue(h) | number: '1.2-2' }}</td> <td class="num">₹{{ carryingValue(h) | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td> <td class="num">{{ h.interestRate }}%</td>
<td>{{ h.purchaseDate }}</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-in">₹{{ accrued(h) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td> <td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
<td> <td>
@@ -203,7 +203,7 @@
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Bond Type</span> <span>* Bond Type</span>
<select [(ngModel)]="purchaseForm.bondType"> <select [ngModel]="purchaseForm.bondType" (ngModelChange)="setBondType($event)">
@for (o of bondTypeOptions; track o.value) { @for (o of bondTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option> <option [value]="o.value">{{ o.label }}</option>
} }
@@ -240,7 +240,7 @@
/></label> /></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Settlement Type</span> <span>* Settlement Type</span>
<select [(ngModel)]="purchaseForm.settlementType"> <select [(ngModel)]="purchaseForm.settlementType" (ngModelChange)="onSettlementTypeChange()">
<option value="T+0">T+0</option> <option value="T+0">T+0</option>
<option value="T+1">T+1</option> <option value="T+1">T+1</option>
<option value="T+2">T+2</option> <option value="T+2">T+2</option>
@@ -264,14 +264,14 @@
} }
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Category</span> <span>* Category</span>
<select [(ngModel)]="purchaseForm.category"> <select [(ngModel)]="purchaseForm.category" [disabled]="isNonSlrPurchase">
<option value="">-- Select --</option> <option value="">-- Select --</option>
@for (c of categoryOptions; track c) { @for (c of purchaseCategoryOptions; track c) {
<option [value]="c">{{ c }}</option> <option [value]="c">{{ c }}</option>
} }
</select> </select>
</label> </label>
@if (!isMutualFund) { @if (!isNonSlrPurchase) {
<label class="inv-form-field" <label class="inv-form-field"
><span>* Last Interest Date</span ><span>* Last Interest Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
@@ -373,7 +373,8 @@
<label class="inv-form-field" <label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="'₹' + (purchaseBookValue | number: '1.2-2')" readonly ><span>Book Value</span><input type="text" [value]="'₹' + (purchaseBookValue | number: '1.2-2')" readonly
/></label> /></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" <label class="inv-form-field"
><span>Premium (+) / Discount (−)</span ><span>Premium (+) / Discount (−)</span
><input type="text" [value]="'₹' + (purchasePremiumDiscount | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (purchasePremiumDiscount | number: '1.2-2')" readonly
@@ -394,7 +395,7 @@
/></label> /></label>
</div> </div>
@if (!isMutualFund && purchaseCouponDates) { @if (!isNonSlrPurchase && purchaseCouponDates) {
<div class="pos-section-label">Interest Cycle</div> <div class="pos-section-label">Interest Cycle</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
@@ -753,7 +754,13 @@
/></label> /></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>{{ <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> }}</span>
<input <input
type="text" type="text"
@@ -786,9 +793,13 @@
· Cr {{ q.premiumGlCode }} Premium ₹{{ q.premiumCleared | number: '1.2-2' }} · Cr {{ q.premiumGlCode }} Premium ₹{{ q.premiumCleared | number: '1.2-2' }}
} }
@if (q.profitLoss > 0) { @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) { } @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> </p>
} }
@@ -1074,7 +1085,7 @@
><input type="number" min="0" [(ngModel)]="sale.units" (change)="refreshSaleQuote()" ><input type="number" min="0" [(ngModel)]="sale.units" (change)="refreshSaleQuote()"
/></label> /></label>
<label class="inv-form-field" <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()" ><input type="number" min="0" step="0.0001" [(ngModel)]="sale.saleRate" (change)="refreshSaleQuote()"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
@@ -1148,7 +1159,9 @@
· Cr {{ q.premiumGlCode }} Premium ₹{{ q.premiumCleared | number: '1.2-2' }} · Cr {{ q.premiumGlCode }} Premium ₹{{ q.premiumCleared | number: '1.2-2' }}
} }
@if (q.profitLoss > 0) { @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) { } @else if (q.profitLoss < 0) {
· Dr 500605 Loss ₹{{ -q.profitLoss | number: '1.2-2' }} · Dr 500605 Loss ₹{{ -q.profitLoss | number: '1.2-2' }}
} }
@@ -1471,7 +1484,7 @@
<span>* Deal No</span> <span>* Deal No</span>
<select [(ngModel)]="goiPurchaseId" (ngModelChange)="onGoiDealChange()"> <select [(ngModel)]="goiPurchaseId" (ngModelChange)="onGoiDealChange()">
<option [ngValue]="null">-- Select --</option> <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> <option [ngValue]="h.id">{{ h.dealNo }} — {{ h.scriptCode }} {{ h.scriptName }}</option>
} }
</select> </select>
@@ -73,6 +73,7 @@ import {
InvestmentSecurityTransferDto, InvestmentSecurityTransferDto,
InvestmentNonSlrLimitDto, InvestmentNonSlrLimitDto,
InvestmentService, InvestmentService,
NON_SLR_TYPES,
apiErrorMessage, apiErrorMessage,
toHolding, toHolding,
toInterestTxn, toInterestTxn,
@@ -200,6 +201,8 @@ export class BondInvestmentComponent implements OnInit {
const tab = this.route.snapshot.queryParamMap.get('tab'); const tab = this.route.snapshot.queryParamMap.get('tab');
if (tab === 'interest' || tab === 'redemption' || tab === 'amortize') { if (tab === 'interest' || tab === 'redemption' || tab === 'amortize') {
this.activeTab = tab; 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({ this.investmentService.syncSharedMasters().subscribe({
error: (err) => this.toast(`Could not load Script / Party masters: ${apiErrorMessage(err)}`) 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.purchaseForm = this.blankPurchaseForm();
this.loadNonSlrLimit(); this.loadNonSlrLimit();
this.loadInterestLog(); this.loadInterestLog();
this.openDeepLinkedDeal();
}, },
error: (err) => this.toast(`Could not load purchases: ${apiErrorMessage(err)}`) 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. */ /** Posted coupons / provisions join the purchases in the transaction log, latest date first. */
private loadInterestLog(): void { private loadInterestLog(): void {
forkJoin({ forkJoin({
@@ -350,12 +376,30 @@ export class BondInvestmentComponent implements OnInit {
} }
setBondType(type: BondType): void { setBondType(type: BondType): void {
const typeChanged = this.purchaseForm.bondType !== type;
this.bondType = type; this.bondType = type;
this.purchaseForm.bondType = type; this.purchaseForm.bondType = type;
this.purchaseForm.subType = this.defaultSubType(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(); 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 { private defaultSubType(type: BondType): string {
return type === 'GOVT_SECURITY' return type === 'GOVT_SECURITY'
? 'SLR' ? '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. */ /** Bonds, CPs, CDs and mutual funds are non-SLR investments, limited by RBI to 10% of deposits. */
get isNonSlrPurchase(): boolean { get isNonSlrPurchase(): boolean {
return [ return NON_SLR_TYPES.includes(this.purchaseForm.bondType);
'NON_SLR',
'BOND',
'MUTUAL_FUND'
].includes(this.purchaseForm.bondType);
} }
loadNonSlrLimit(): void { loadNonSlrLimit(): void {
@@ -530,7 +570,8 @@ export class BondInvestmentComponent implements OnInit {
fromDist: '', fromDist: '',
toDist: '', toDist: '',
redumMatDt: '', redumMatDt: '',
category: '', // Non-SLR (bond, mutual fund) is always AFS (Jitendra Sir).
category: NON_SLR_TYPES.includes(type) ? 'AFS (Available For Sale)' : '',
intRate: null, intRate: null,
intType: 'HALF_YEARLY', intType: 'HALF_YEARLY',
intDate: this.asOnDate, intDate: this.asOnDate,
@@ -640,13 +681,19 @@ export class BondInvestmentComponent implements OnInit {
if (!f.intRate || !this.purchaseMaturityDate) { if (!f.intRate || !this.purchaseMaturityDate) {
return 0; 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. */ /** Broken-period interest owed to the seller from the last coupon date up to the settlement date, 30/360. */
get purchaseAccruedInterest(): number { get purchaseAccruedInterest(): number {
const f = this.purchaseForm; 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 0;
} }
return Math.round((this.purchaseBaseAmount * f.intRate * this.purchaseAccruedDays) / 360) / 100; 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. */ /** Premium (+) or discount (−) paid over face value. */
get purchasePremiumDiscount(): number { get purchasePremiumDiscount(): number {
const f = this.purchaseForm; 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 0;
} }
return Math.round(f.faceValue * (f.purchaseRate - 100)) / 100; return Math.round(f.faceValue * (f.purchaseRate - 100)) / 100;
@@ -682,9 +730,21 @@ export class BondInvestmentComponent implements OnInit {
private suggestLastIntDate(): void { private suggestLastIntDate(): void {
const f = this.purchaseForm; const f = this.purchaseForm;
const s = this.purchaseScript; const s = this.purchaseScript;
if (!this.isMutualFund && s?.maturityDate && f.dealDate) { // The last coupon on or before SETTLEMENT (one falling between deal and settlement goes to the seller), or the
f.lastIntDt = lastHalfYearlyCouponDate(s.maturityDate, f.dealDate) || f.lastIntDt; // 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. */ /** Settlement Amt of Purchase — book value, accrued interest payable to the seller and the charges. */
@@ -817,7 +877,8 @@ export class BondInvestmentComponent implements OnInit {
/** Parties offered for a new deal — inactive parties are hidden. */ /** Parties offered for a new deal — inactive parties are hidden. */
get activePartyOptions(): typeof PARTY_MASTER { 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 { get purchaseInvestmentGlCode(): string {
@@ -898,7 +959,8 @@ export class BondInvestmentComponent implements OnInit {
} else { } else {
if (!f.faceValue) missing.push('Face Value'); if (!f.faceValue) missing.push('Face Value');
if (!f.purchaseRate) missing.push('Purchase Rate'); 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.category) missing.push('Category');
if (!f.crToAc) missing.push('CR. To A/c'); if (!f.crToAc) missing.push('CR. To A/c');
@@ -926,7 +988,11 @@ export class BondInvestmentComponent implements OnInit {
settlementType: f.settlementType, settlementType: f.settlementType,
...(this.isMutualFund ...(this.isMutualFund
? { considerAmt: f.considerAmt, nav: f.nav } ? { 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, stampDuty: f.stampDuty ?? null,
brokerage: f.brokerage ?? null, brokerage: f.brokerage ?? null,
otherCharges: f.otherCharges ?? null, otherCharges: f.otherCharges ?? null,
@@ -1095,14 +1161,24 @@ export class BondInvestmentComponent implements OnInit {
redeeming = false; redeeming = false;
/** Settled holdings of this type still held — the ones that can be sold or redeemed. */ /** 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[] { get saleableHoldings(): InvestmentHolding[] {
return this.activeHoldingsForType.filter((h) => !!h.id); 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[] { get redeemableHoldings(): InvestmentHolding[] {
const today = parseDDMMYYYY(this.asOnDate); 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). */ /** 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) =>
h.id != null && h.id != null &&
h.status === 'ACTIVE' && h.status === 'ACTIVE' &&
h.instrumentType === 'GOVT_SECURITY' &&
(!this.intType || h.instrumentType === this.intType) && (!this.intType || h.instrumentType === this.intType) &&
(!this.intScript || h.scriptCode === this.intScript) (!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. */ /** Total of the lines falling due up to the as-on date. */
get intDueTotal(): number { 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 -------------------- // -------------------- 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. */ /** 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 { refreshSaleQuote(): void {
const f = this.sale; const f = this.sale;
const seq = ++this.saleQuoteSeq;
this.saleQuote = null; this.saleQuote = null;
this.saleQuoteError = ''; this.saleQuoteError = '';
if (!f.purchaseId || !f.units || !f.saleRate || !f.dealDate) { if (!f.purchaseId || !f.units || !f.saleRate || !f.dealDate) {
@@ -1329,8 +1410,16 @@ export class BondInvestmentComponent implements OnInit {
...(bankGlAccountId ? { bankGlAccountId } : {}) ...(bankGlAccountId ? { bankGlAccountId } : {})
}) })
.subscribe({ .subscribe({
next: (q) => (this.saleQuote = q), next: (q) => {
error: (err) => (this.saleQuoteError = apiErrorMessage(err)) 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; 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. */ /** Book value of a holding as it stands: cost less what sales and write-offs have taken off. */
carryingValue(h: InvestmentHolding): number { carryingValue(h: InvestmentHolding): number {
const taken = this.sales const taken = this.sales
@@ -1573,6 +1667,11 @@ export class BondInvestmentComponent implements OnInit {
goiRemarks = ''; goiRemarks = '';
goiSaving = false; 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 { get goiHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.id === this.goiPurchaseId); 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. * Coupons fall every six months from the purchase's last interest date; a coupon received resets it to zero.
*/ */
accrued(h: InvestmentHolding): number { 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') { if (!h.lastIntDate || h.status !== 'ACTIVE') {
return h.lastIntDate ? 0 : accruedInterest(h, this.asOnDate); 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; 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 { maturity(h: InvestmentHolding): number {
return maturityAmount(h); return h.instrumentType === 'MUTUAL_FUND' || h.instrumentType === 'SHARE'
? this.carryingValue(h)
: maturityAmount(h);
} }
daysLeft(h: InvestmentHolding): number { daysLeft(h: InvestmentHolding): number {
@@ -1833,7 +1939,7 @@ export class BondInvestmentComponent implements OnInit {
h.faceValue, h.faceValue,
h.interestRate, h.interestRate,
h.purchaseDate, h.purchaseDate,
h.maturityDate, h.instrumentType === 'MUTUAL_FUND' ? '' : h.maturityDate,
this.accrued(h), this.accrued(h),
this.maturity(h), this.maturity(h),
h.status h.status
@@ -254,7 +254,8 @@
/></label> /></label>
@if (q.payout !== 'MATURITY') { @if (q.payout !== 'MATURITY') {
<label class="inv-form-field" <label class="inv-form-field"
><span>Interest per {{ q.payout === 'HALF_YEARLY' ? 'half year' : 'year' }}</span ><span
>Approx. interest per {{ q.payout === 'HALF_YEARLY' ? 'half year' : 'year' }} (actual days vary)</span
><input type="text" [value]="'₹' + (q.periodInterest | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (q.periodInterest | number: '1.2-2')" readonly
/></label> /></label>
} }
@@ -415,8 +416,10 @@
> >
Generate Voucher Generate Voucher
</button> </button>
} @else { } @else if (isDueSoon(r.intDate)) {
🔔 Due {{ r.intDate }} 🔔 Due {{ r.intDate }}
} @else {
Scheduled {{ r.intDate }}
} }
</td> </td>
</tr> </tr>
@@ -137,6 +137,12 @@ export class FdInvestmentComponent implements OnInit {
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
/** An unposted line is due from about two days before its date (the reminder window); before that it is scheduled. */
isDueSoon(intDate: string): boolean {
const due = parseDDMMYYYY(intDate).getTime();
return due - parseDDMMYYYY(todayDDMMYYYY()).getTime() <= 2 * 24 * 3600 * 1000;
}
ngOnInit(): void { ngOnInit(): void {
this.investmentService.getParties().subscribe({ this.investmentService.getParties().subscribe({
next: (ps) => (this.fdParties = ps.filter((p) => p.bondType === 'FD' && p.active && !!p.interestGlAccountId)), next: (ps) => (this.fdParties = ps.filter((p) => p.bondType === 'FD' && p.active && !!p.interestGlAccountId)),
@@ -20,8 +20,10 @@ import {
InvestmentInterestReportDto, InvestmentInterestReportDto,
InvestmentNonSlrLimitDto, InvestmentNonSlrLimitDto,
InvestmentPurchaseDto, InvestmentPurchaseDto,
InvestmentSaleDto,
InvestmentService, InvestmentService,
apiErrorMessage apiErrorMessage,
bookValueSoldByPurchase
} from '../investment.service'; } from '../investment.service';
interface InstrumentSummary { interface InstrumentSummary {
@@ -114,12 +116,13 @@ export class InvestmentDashboardComponent implements OnInit {
bucket: this.investmentService.getBucketReport(today), bucket: this.investmentService.getBucketReport(today),
interest: this.investmentService.getInterestReport(fyStart, today), interest: this.investmentService.getInterestReport(fyStart, today),
purchases: this.investmentService.getPurchases(), purchases: this.investmentService.getPurchases(),
sales: this.investmentService.getSales(),
fds: this.investmentService.getFds(), fds: this.investmentService.getFds(),
fdQuote: this.investmentService.quoteFd({ amount: 0, intRate: 0, dealDate: today }), fdQuote: this.investmentService.quoteFd({ amount: 0, intRate: 0, dealDate: today }),
nonSlr: this.investmentService.getNonSlrLimit(null, today, 0) nonSlr: this.investmentService.getNonSlrLimit(null, today, 0)
}).subscribe({ }).subscribe({
next: (r) => { next: (r) => {
this.build(r.bucket, r.interest, r.purchases, r.fds, r.fdQuote.limit ?? null, r.nonSlr); this.build(r.bucket, r.interest, r.purchases, r.sales, r.fds, r.fdQuote.limit ?? null, r.nonSlr);
this.loading = false; this.loading = false;
}, },
error: (err: unknown) => { error: (err: unknown) => {
@@ -133,6 +136,7 @@ export class InvestmentDashboardComponent implements OnInit {
bucket: InvestmentBucketReportDto, bucket: InvestmentBucketReportDto,
interest: InvestmentInterestReportDto, interest: InvestmentInterestReportDto,
purchases: InvestmentPurchaseDto[], purchases: InvestmentPurchaseDto[],
sales: InvestmentSaleDto[],
fds: InvestmentFdDto[], fds: InvestmentFdDto[],
fdLimit: InvestmentFdLimitDto | null, fdLimit: InvestmentFdLimitDto | null,
nonSlr: InvestmentNonSlrLimitDto nonSlr: InvestmentNonSlrLimitDto
@@ -192,14 +196,16 @@ export class InvestmentDashboardComponent implements OnInit {
s.maturityValue += s.maturityValue +=
r.instrument === 'FD' ? (fdById.get(r.refId)?.expectedMaturityAmount ?? r.faceValue) : r.faceValue; 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. // Mutual funds and shares have no maturity, so they are not in the buckets; the units still held are at cost.
const sold = bookValueSoldByPurchase(sales);
for (const p of purchases) { for (const p of purchases) {
if ((p.bondType === 'MUTUAL_FUND' || p.bondType === 'SHARE') && p.status === 'ACTIVE') { if ((p.bondType === 'MUTUAL_FUND' || p.bondType === 'SHARE') && p.status === 'ACTIVE') {
const s = sum(p.bondType); const s = sum(p.bondType);
const book = p.bookValue - (sold.get(p.id) ?? 0);
s.count += 1; s.count += 1;
s.faceValue += p.faceValue; s.faceValue += p.units ? (p.faceValue * p.balanceUnits) / p.units : p.faceValue;
s.bookValue += p.bookValue; s.bookValue += book;
s.maturityValue += p.bookValue; s.maturityValue += book;
} }
} }
const order: InstrumentType[] = [ const order: InstrumentType[] = [
+6 -1
View File
@@ -29,7 +29,9 @@ export type TxnType =
| 'WRITE_OFF' | 'WRITE_OFF'
| 'AMORTIZE' | 'AMORTIZE'
| 'TRANSFER' | 'TRANSFER'
| 'PART_PAYMENT'; | 'PART_PAYMENT'
| 'PREMATURE'
| 'RENEWAL';
export const INSTRUMENT_LABELS: Record<InstrumentType, string> = { export const INSTRUMENT_LABELS: Record<InstrumentType, string> = {
FD: 'Fixed Deposit', FD: 'Fixed Deposit',
@@ -134,6 +136,9 @@ export interface InvestmentHolding {
partyCode?: string; partyCode?: string;
bookValue?: number; bookValue?: number;
settlementAmount?: number; settlementAmount?: number;
/** How and when a closed FD ended (MATURITY / PREMATURE / RENEWAL). */
closureType?: string;
closedOnDate?: string;
/** Settlement date of the deal (deal date + T+n) — it can be settled on or after this day. */ /** Settlement date of the deal (deal date + T+n) — it can be settled on or after this day. */
settlementDate?: string; settlementDate?: string;
/** Last coupon date before the purchase; coupons fall every six months from it. */ /** Last coupon date before the purchase; coupons fall every six months from it. */
@@ -113,7 +113,7 @@ export class InvestmentDiaryComponent implements OnInit {
return { path: '/investment/fd', query: { tab: 'interest', fd: r.refId } }; return { path: '/investment/fd', query: { tab: 'interest', fd: r.refId } };
} }
const tab = r.kind === 'MATURITY' ? 'redemption' : r.kind === 'AMORTISATION' ? 'amortize' : 'interest'; const tab = r.kind === 'MATURITY' ? 'redemption' : r.kind === 'AMORTISATION' ? 'amortize' : 'interest';
return { path: '/investment/bonds', query: { tab } }; return { path: '/investment/bonds', query: { tab, purchase: r.refId } };
} }
statusLabel(r: InvestmentDiaryRowDto): string { statusLabel(r: InvestmentDiaryRowDto): string {
@@ -301,6 +301,10 @@
<label class="inv-form-field" <label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}ISIN</span><input type="text" [(ngModel)]="scriptForm.isin" ><span>{{ scriptIsDebtSecurity ? '* ' : '' }}ISIN</span><input type="text" [(ngModel)]="scriptForm.isin"
/></label> /></label>
<label class="inv-form-field"
><span>Face Value per Unit</span
><input type="number" min="0" step="0.01" placeholder="100" [(ngModel)]="scriptForm.faceValuePerUnit"
/></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Int Rate %</span ><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Int Rate %</span
><input type="number" step="0.01" min="0" [(ngModel)]="scriptForm.couponRate" ><input type="number" step="0.01" min="0" [(ngModel)]="scriptForm.couponRate"
@@ -364,7 +368,7 @@
{{ {{
scriptRatingEligible scriptRatingEligible
? 'Rating meets the RBI minimum; this security can be bought.' ? 'Rating meets the RBI minimum; this security can be bought.'
: 'Below the RBI minimum rating; a purchase of this security will be blocked.' : 'Below the RBI minimum rating, or the wrong scale (CP / CD up to one year: A1+ to A4; bond: AAA to D).'
}} }}
</p> </p>
} }
@@ -604,21 +608,36 @@
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Open Rate</span><input type="number" [(ngModel)]="newDailyRate.openRate" step="0.01" ><span>Open Rate</span><input type="number" [(ngModel)]="newDailyRate.openRate" step="0.0001"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Close Rate *</span><input type="number" [(ngModel)]="newDailyRate.closeRate" step="0.01" ><span>Close Rate *</span><input type="number" [(ngModel)]="newDailyRate.closeRate" step="0.0001"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>High Rate</span><input type="number" [(ngModel)]="newDailyRate.highRate" step="0.01" ><span>High Rate</span><input type="number" [(ngModel)]="newDailyRate.highRate" step="0.0001"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Low Rate</span><input type="number" [(ngModel)]="newDailyRate.lowRate" step="0.01" ><span>Low Rate</span><input type="number" [(ngModel)]="newDailyRate.lowRate" step="0.0001"
/></label> /></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addDailyRate()">Save</button> <button type="button" class="pg-btn pg-btn--primary" (click)="addDailyRate()">Save</button>
<label class="pg-btn pg-btn--ghost">
{{ uploading ? 'Uploading…' : 'Upload CSV / Excel' }}
<input type="file" accept=".csv,.xlsx" hidden [disabled]="uploading" (change)="uploadDailyRates($event)" />
</label>
</div> </div>
<p class="inv-hint">
File columns: Script Code (or ISIN), Rate Date (DD-MM-YYYY), Close, Open, High, Low — first row is the header.
Use it for the day's market rates and mutual fund NAVs.
</p>
@if (uploadResults.length) {
<div class="inv-form-grid">
@for (r of uploadResults; track r.row) {
<p class="inv-hint" [class.inv-hint--bad]="!r.ok">Row {{ r.row }}: {{ r.text }}</p>
}
</div>
}
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
@@ -640,10 +659,11 @@
<span class="pg-chip">{{ r.scriptCode }}</span> <span class="pg-chip">{{ r.scriptCode }}</span>
</td> </td>
<td>{{ r.scriptName }}</td> <td>{{ r.scriptName }}</td>
<td class="num">{{ r.openRate | number: '1.2-2' }}</td> <!-- Mutual fund NAVs run to 4 decimals. -->
<td class="num">{{ r.highRate | number: '1.2-2' }}</td> <td class="num">{{ r.openRate | number: '1.2-4' }}</td>
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td> <td class="num">{{ r.highRate | number: '1.2-4' }}</td>
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td> <td class="num">{{ r.lowRate | number: '1.2-4' }}</td>
<td class="num">{{ r.closeRate | number: '1.2-4' }}</td>
<td> <td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropDailyRate(r)">Drop</button> <button type="button" class="pg-btn pg-btn--ghost" (click)="dropDailyRate(r)">Drop</button>
</td> </td>
@@ -9,6 +9,7 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component'; import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { import {
BROKER_MASTER, BROKER_MASTER,
@@ -33,6 +34,7 @@ import {
SLR_TYPE_OPTIONS, SLR_TYPE_OPTIONS,
INVESTMENT_GL_LOOKUP, INVESTMENT_GL_LOOKUP,
ELIGIBLE_RATINGS, ELIGIBLE_RATINGS,
parseDDMMYYYY,
RATING_AGENCY_OPTIONS, RATING_AGENCY_OPTIONS,
RATING_OPTIONS, RATING_OPTIONS,
periodBetween periodBetween
@@ -310,7 +312,19 @@ export class InvestmentMastersComponent implements OnInit {
/** Whether the rating entered meets the RBI minimum: "A" for bonds, "A2" for CP / CD. */ /** Whether the rating entered meets the RBI minimum: "A" for bonds, "A2" for CP / CD. */
get scriptRatingEligible(): boolean { get scriptRatingEligible(): boolean {
return !!this.scriptForm.rating && ELIGIBLE_RATINGS.includes(this.scriptForm.rating); const r = this.scriptForm.rating;
return !!r && ELIGIBLE_RATINGS.includes(r) && this.scriptIsShortTerm === /^A[1-4]\+?$/.test(r);
}
/** A CP / CD runs one year or less and takes a short-term rating (A1+ to A4); a bond takes a long-term one. */
get scriptIsShortTerm(): boolean {
const f = this.scriptForm;
if (!f.issueDate || !f.maturityDate) {
return false;
}
const issue = parseDDMMYYYY(f.issueDate);
const limit = new Date(issue.getFullYear() + 1, issue.getMonth(), issue.getDate());
return parseDDMMYYYY(f.maturityDate) <= limit;
} }
/** Preview of the code the backend will assign — a type letter + running number, e.g. G00001. */ /** Preview of the code the backend will assign — a type letter + running number, e.g. G00001. */
@@ -361,6 +375,8 @@ export class InvestmentMastersComponent implements OnInit {
glAccountId: Number(f.glAccountId), glAccountId: Number(f.glAccountId),
isin: f.isin ?? '', isin: f.isin ?? '',
intRate: f.couponRate ?? null, intRate: f.couponRate ?? null,
// Units = face value / face value per unit (100 for G-Secs by default; bonds can be 1,000 or 10,00,000).
...(f.faceValuePerUnit ? { faceValuePerUnit: Number(f.faceValuePerUnit) } : {}),
issueDate: f.issueDate ?? '', issueDate: f.issueDate ?? '',
maturityDate: f.maturityDate ?? '', maturityDate: f.maturityDate ?? '',
companyName: f.companyName ?? '', companyName: f.companyName ?? '',
@@ -541,6 +557,113 @@ export class InvestmentMastersComponent implements OnInit {
}); });
} }
// -------------------- Daily rate upload (CSV / Excel) --------------------
/** Result of the last upload, one line per row of the file. */
uploadResults: Array<{ row: number; text: string; ok: boolean }> = [];
uploading = false;
/**
* Uploads the day's rates / NAVs from a CSV or Excel file (Jitendra Sir): columns Script Code (or ISIN), Rate Date
* (DD-MM-YYYY), Close, and optionally Open, High, Low; the first row is the header. Each row is saved on its own,
* so one bad row does not stop the rest.
*/
async uploadDailyRates(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
this.uploadResults = [];
let rows: string[][];
try {
rows = file.name.toLowerCase().endsWith('.xlsx') ? await this.readXlsx(file) : this.readCsv(await file.text());
} catch {
this.toast('Could not read the file. Use CSV or Excel (.xlsx).');
return;
}
const data = rows.slice(1).filter((r) => r.some((c) => c.trim() !== ''));
if (!data.length) {
this.toast('The file has no rate rows.');
return;
}
this.uploading = true;
for (let i = 0; i < data.length; i++) {
const [
code,
rateDate,
close,
open,
high,
low
] = data[i].map((c) => c.trim());
const script = this.scripts.find(
(s) =>
s.scriptCode.toUpperCase() === code.toUpperCase() || (!!s.isin && s.isin.toUpperCase() === code.toUpperCase())
);
const num = (v?: string) => (v ? Number(v.replace(/,/g, '')) : null);
if (!script?.id || !rateDate || num(close) == null || isNaN(num(close) as number)) {
this.uploadResults.push({
row: i + 2,
text: `${code || '?'}: script not found or date / close missing`,
ok: false
});
continue;
}
const body = {
scriptId: script.id,
rateDate,
closeRate: num(close),
...(num(open) != null ? { openRate: num(open) } : {}),
...(num(high) != null ? { highRate: num(high) } : {}),
...(num(low) != null ? { lowRate: num(low) } : {}),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
try {
await firstValueFrom(this.investmentService.createDailyRate(body, true));
this.uploadResults.push({ row: i + 2, text: `${script.scriptCode} ${rateDate}: saved`, ok: true });
} catch (err) {
this.uploadResults.push({
row: i + 2,
text: `${script.scriptCode} ${rateDate}: ${apiErrorMessage(err)}`,
ok: false
});
}
}
this.uploading = false;
const saved = this.uploadResults.filter((r) => r.ok).length;
this.toast(`${saved} of ${data.length} rate(s) saved.`);
this.loadDailyRates();
}
private readCsv(text: string): string[][] {
return text.split(/\r?\n/).map((line) => line.split(',').map((c) => c.replace(/^"|"$/g, '')));
}
private async readXlsx(file: File): Promise<string[][]> {
const ExcelJS = await import('exceljs');
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(await file.arrayBuffer());
const sheet = wb.worksheets[0];
const rows: string[][] = [];
sheet.eachRow({ includeEmpty: false }, (row) => {
const cells: string[] = [];
for (let c = 1; c <= 6; c++) {
const v = row.getCell(c).value;
cells.push(
v instanceof Date
? `${String(v.getDate()).padStart(2, '0')}-${String(v.getMonth() + 1).padStart(2, '0')}-${v.getFullYear()}`
: v == null
? ''
: String(v)
);
}
rows.push(cells);
});
return rows;
}
/** A wrong rate is corrected by dropping it and entering it again. */ /** A wrong rate is corrected by dropping it and entering it again. */
dropDailyRate(r: DailyScriptRate): void { dropDailyRate(r: DailyScriptRate): void {
if (!r.id) { if (!r.id) {
@@ -122,7 +122,8 @@
<th>Script / Party</th> <th>Script / Party</th>
<th class="num">Units</th> <th class="num">Units</th>
<th class="num">Face Value</th> <th class="num">Face Value</th>
<th class="num">Market/Purchase Rate</th> <th class="num">Book Value</th>
<th class="num">Rate</th>
<th>Status</th> <th>Status</th>
</tr> </tr>
</thead> </thead>
@@ -142,14 +143,21 @@
<td>{{ h.scriptName }} — {{ h.party }}</td> <td>{{ h.scriptName }} — {{ h.party }}</td>
<td class="num">{{ h.units | number }}</td> <td class="num">{{ h.units | number }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td> <td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ marketPrice(h.scriptCode) || h.purchaseRate }}</td> <td class="num">₹{{ h.bookValue ?? h.faceValue | number: '1.2-2' }}</td>
<td class="num">
@if (marketPrice(h.scriptCode); as mp) {
{{ mp }} <small>(market {{ marketRateDate(h.scriptCode) }})</small>
} @else {
{{ h.purchaseRate }} <small>(purchase)</small>
}
</td>
<td> <td>
<span [class]="statusClass(h)">{{ h.status }}</span> <span [class]="statusClass(h)">{{ h.status }}</span>
</td> </td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="6" class="pg-empty"> <td colspan="7" class="pg-empty">
{{ activeReport === 'shareBalance' ? 'Share entry is not live yet.' : 'No holdings.' }} {{ activeReport === 'shareBalance' ? 'Share entry is not live yet.' : 'No holdings.' }}
</td> </td>
</tr> </tr>
@@ -160,7 +168,7 @@
} }
@if (activeReport === 'bankInvList') { @if (activeReport === 'bankInvList') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Bank Investment List</div> <div class="pos-section-label">Bank Investment List — FDs held with each bank</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
@@ -383,30 +391,42 @@
} }
@if (activeReport === 'pendingReceived') { @if (activeReport === 'pendingReceived') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Received Interest / Dividend</div> <div class="pos-section-label">
Interest Received — {{ receivedFrom }} to {{ asOnDate }} (coupons, FD payouts and FD interest paid at closure)
</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Investment</th>
<th>Cert No</th> <th>Deal No</th>
<th>Particulars</th> <th>Particulars</th>
<th class="num col-in">Amount</th> <th class="num col-in">Received</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (t of receivedInterestTxns; track $index) { @for (x of receivedRows; track x.instrument + ':' + x.refId) {
<tr> <tr>
<td>{{ t.txnDate }}</td> <td>{{ x.instrument === 'FD' ? 'FD' : 'Govt Security / Bond' }}</td>
<td>{{ t.certNo }}</td> <td>
<td>{{ t.particulars }}</td> <span class="pg-chip">{{ x.dealNo }}</span>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td> </td>
<td>{{ x.particulars }}</td>
<td class="num col-in">₹{{ x.received | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="4" class="pg-empty">No interest received yet.</td> <td colspan="4" class="pg-empty">No interest received in this period.</td>
</tr> </tr>
} }
</tbody> </tbody>
@if (receivedRows.length) {
<tfoot>
<tr class="cctr-total-row">
<td colspan="3">Total</td>
<td class="num">₹{{ receivedTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
}
</table> </table>
</div> </div>
<div class="pg-card"> <div class="pg-card">
@@ -514,6 +534,8 @@
<th>Party</th> <th>Party</th>
<th class="num">Face Value</th> <th class="num">Face Value</th>
<th>Maturity Date</th> <th>Maturity Date</th>
<th>Closed As</th>
<th>Closed On</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -525,10 +547,12 @@
<td>{{ h.party }}</td> <td>{{ h.party }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td> <td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td>{{ h.maturityDate }}</td> <td>{{ h.maturityDate }}</td>
<td>{{ h.closureType || '—' }}</td>
<td>{{ h.closedOnDate || '—' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="4" class="pg-empty">No matured FDs.</td> <td colspan="6" class="pg-empty">No matured FDs.</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -642,7 +666,7 @@
} }
@if (activeReport === 'shiftingReport') { @if (activeReport === 'shiftingReport') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Shifting Report — Govt Securities transferred to another script / category</div> <div class="pos-section-label">Shifting Report — securities transferred to another script / category</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
@@ -25,9 +25,11 @@ import {
InvestmentDailyRateDto, InvestmentDailyRateDto,
InvestmentFdDto, InvestmentFdDto,
InvestmentFdScheduleDto, InvestmentFdScheduleDto,
InvestmentInterestReportRowDto,
InvestmentSecurityTransferDto, InvestmentSecurityTransferDto,
InvestmentService, InvestmentService,
apiErrorMessage, apiErrorMessage,
bookValueSoldByPurchase,
toFdHolding, toFdHolding,
toFdTxns, toFdTxns,
toHolding, toHolding,
@@ -75,8 +77,11 @@ export class InvestmentReportsComponent implements OnInit {
/** Saved purchases and FDs as holdings, and their GL entries, newest first. */ /** Saved purchases and FDs as holdings, and their GL entries, newest first. */
holdings: InvestmentHolding[] = []; holdings: InvestmentHolding[] = [];
txns: InvestmentTxn[] = []; txns: InvestmentTxn[] = [];
/** Coupons and FD interest paid out — interest actually received. */ /** Interest received in the financial year to the as-on date, per deal / FD (Interest Received & Receivable report). */
receivedTxns: InvestmentTxn[] = []; receivedRows: InvestmentInterestReportRowDto[] = [];
receivedFrom = '';
/** Every purchase deal, pending ones included — the Deal Slip register (the ledgers hold only what reached the GL). */
dealSlipTxns: InvestmentTxn[] = [];
/** GOI category transfers (security shifted to another script / category). */ /** GOI category transfers (security shifted to another script / category). */
transfers: InvestmentSecurityTransferDto[] = []; transfers: InvestmentSecurityTransferDto[] = [];
/** FD interest lines need one call per FD, so they load when a report needs them. */ /** FD interest lines need one call per FD, so they load when a report needs them. */
@@ -85,6 +90,7 @@ export class InvestmentReportsComponent implements OnInit {
private receivable = new Map<string, number>(); private receivable = new Map<string, number>();
private fdMaturity = new Map<number, number>(); private fdMaturity = new Map<number, number>();
private latestRate = new Map<string, number>(); private latestRate = new Map<string, number>();
private latestRateDate = new Map<string, string>();
ngOnInit(): void { ngOnInit(): void {
this.load(); this.load();
@@ -108,18 +114,24 @@ export class InvestmentReportsComponent implements OnInit {
next: (r) => { next: (r) => {
this.fds = r.fds; this.fds = r.fds;
this.transfers = r.transfers; this.transfers = r.transfers;
// A holding is shown at the book value still held: cost less what settled sales and write-offs took off.
const sold = bookValueSoldByPurchase(r.sales);
this.holdings = [ this.holdings = [
...r.purchases.map(toHolding), ...r.purchases.map((p) => ({ ...toHolding(p), bookValue: p.bookValue - (sold.get(p.id) ?? 0) })),
...r.fds.map(toFdHolding) ...r.fds.map(toFdHolding)
]; ];
// Ledgers show what reached the GL: settled purchases and sales only.
this.txns = this.byDateDesc([ this.txns = this.byDateDesc([
...r.purchases.map(toPurchaseTxn), ...r.purchases.filter((p) => !!p.glTransactionId).map(toPurchaseTxn),
...r.postings.map(toInterestTxn), ...r.postings.map(toInterestTxn),
...r.sales.map(toSaleTxn), ...r.sales.filter((x) => x.status === 'SETTLED').map(toSaleTxn),
...r.transfers.map(toTransferTxn), ...r.transfers.map(toTransferTxn),
...r.fds.flatMap((f) => toFdTxns(f, [])) ...r.fds.flatMap((f) => toFdTxns(f, []))
]); ]);
this.receivedTxns = this.byDateDesc(r.postings.filter((x) => x.lineType === 'COUPON').map(toInterestTxn)); this.dealSlipTxns = this.byDateDesc(r.purchases.map(toPurchaseTxn));
// Received = coupons, FD payouts and FD interest paid at closure, as the interest report counts them.
this.receivedRows = r.interest.rows.filter((x) => x.received > 0);
this.receivedFrom = r.interest.fromDate;
this.receivable = new Map( this.receivable = new Map(
r.interest.rows.map((x) => [ r.interest.rows.map((x) => [
x.instrument + ':' + x.refId, x.instrument + ':' + x.refId,
@@ -140,9 +152,12 @@ export class InvestmentReportsComponent implements OnInit {
[...r.rates] [...r.rates]
.filter((x) => parseDDMMYYYY(x.rateDate).getTime() <= asOn) .filter((x) => parseDDMMYYYY(x.rateDate).getTime() <= asOn)
.sort((a, b) => parseDDMMYYYY(a.rateDate).getTime() - parseDDMMYYYY(b.rateDate).getTime()) .sort((a, b) => parseDDMMYYYY(a.rateDate).getTime() - parseDDMMYYYY(b.rateDate).getTime())
.forEach((x) => this.latestRate.set(x.scriptCode, x.closeRate)); .forEach((x) => {
this.latestRate.set(x.scriptCode, x.closeRate);
this.latestRateDate.set(x.scriptCode, x.rateDate);
});
this.loading = false; this.loading = false;
if (this.activeReport === 'fdLedger' || this.activeReport === 'pendingReceived') { if (this.activeReport === 'fdLedger') {
this.loadFdInterest(); this.loadFdInterest();
} }
}, },
@@ -168,23 +183,15 @@ export class InvestmentReportsComponent implements OnInit {
) )
).subscribe((schedules) => { ).subscribe((schedules) => {
const lines: InvestmentTxn[] = []; const lines: InvestmentTxn[] = [];
const received: InvestmentTxn[] = [];
schedules.forEach((s, i) => { schedules.forEach((s, i) => {
if (!s) { if (s) {
return; lines.push(...toFdTxns(withId[i], s.rows).filter((t) => t.txnType === 'INTEREST'));
} }
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 = this.byDateDesc([
...this.txns, ...this.txns,
...lines ...lines
]); ]);
this.receivedTxns = this.byDateDesc([
...this.receivedTxns,
...received
]);
}); });
} }
@@ -201,7 +208,7 @@ export class InvestmentReportsComponent implements OnInit {
setReport(report: string): void { setReport(report: string): void {
this.activeReport = report; this.activeReport = report;
if (report === 'fdLedger' || report === 'pendingReceived') { if (report === 'fdLedger') {
this.loadFdInterest(); this.loadFdInterest();
} }
} }
@@ -238,6 +245,11 @@ export class InvestmentReportsComponent implements OnInit {
return this.latestRate.get(scriptCode) ?? null; return this.latestRate.get(scriptCode) ?? null;
} }
/** Date of that latest close. */
marketRateDate(scriptCode: string): string {
return this.latestRateDate.get(scriptCode) ?? '';
}
private active(type?: InstrumentType): InvestmentHolding[] { private active(type?: InstrumentType): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && (!type || h.instrumentType === type)); return this.holdings.filter((h) => h.status === 'ACTIVE' && (!type || h.instrumentType === type));
} }
@@ -256,9 +268,10 @@ export class InvestmentReportsComponent implements OnInit {
get fdHoldings(): InvestmentHolding[] { get fdHoldings(): InvestmentHolding[] {
return this.active('FD'); return this.active('FD');
} }
/** FDs held with each bank (principal) — securities bought from a dealer are not 'with' that bank. */
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 this.active()) { for (const h of this.active('FD')) {
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;
@@ -286,7 +299,7 @@ export class InvestmentReportsComponent implements OnInit {
return this.txns.filter((t) => t.txnType === 'AMORTIZE'); return this.txns.filter((t) => t.txnType === 'AMORTIZE');
} }
get dealSlips(): InvestmentTxn[] { get dealSlips(): InvestmentTxn[] {
return this.txns.filter((t) => t.txnType === 'PURCHASE' && t.instrumentType !== 'FD'); return this.dealSlipTxns;
} }
get slrBondRegister(): InvestmentHolding[] { get slrBondRegister(): InvestmentHolding[] {
return this.active('GOVT_SECURITY'); return this.active('GOVT_SECURITY');
@@ -297,8 +310,8 @@ export class InvestmentReportsComponent implements OnInit {
.map((h) => ({ ...h, dueAmount: this.accrued(h) })) .map((h) => ({ ...h, dueAmount: this.accrued(h) }))
.filter((h) => h.dueAmount > 0); .filter((h) => h.dueAmount > 0);
} }
get receivedInterestTxns(): InvestmentTxn[] { get receivedTotal(): number {
return this.receivedTxns; return this.receivedRows.reduce((t, x) => t + x.received, 0);
} }
get pendingInterestHoldings(): InvestmentHolding[] { get pendingInterestHoldings(): InvestmentHolding[] {
return this.active().filter((h) => this.accrued(h) > 0); return this.active().filter((h) => this.accrued(h) > 0);
@@ -406,7 +419,7 @@ export class InvestmentReportsComponent implements OnInit {
h.purchaseRate, h.purchaseRate,
h.interestRate, h.interestRate,
h.purchaseDate, h.purchaseDate,
h.maturityDate, h.instrumentType === 'MUTUAL_FUND' ? '' : h.maturityDate,
this.accrued(h), this.accrued(h),
this.maturity(h), this.maturity(h),
h.status h.status
@@ -539,7 +552,7 @@ export class InvestmentReportsComponent implements OnInit {
case 'intDueRegister': case 'intDueRegister':
return one( return one(
'Interest Due Register', 'Interest Due Register',
'Interest Due Register (Non-Cumulative Holdings)', 'Interest Due Register (interest earned, not yet received)',
[ [
...this.holdingsExportHeaders, ...this.holdingsExportHeaders,
'Due Amount' 'Due Amount'
@@ -565,11 +578,34 @@ export class InvestmentReportsComponent implements OnInit {
return [ return [
{ {
sheetName: 'Received Interest', sheetName: 'Received Interest',
title: 'Received Interest / Dividend', title: 'Interest Received',
subtitle, subtitle: `${this.receivedFrom} to ${this.asOnDate}`,
headers: this.txnExportHeaders, headers: [
widths: this.txnExportWidths, 'Investment',
rows: this.receivedInterestTxns.map((t) => this.txnExportRow(t)) 'Deal No',
'Particulars',
'Received'
],
widths: [
14,
16,
44,
16
],
rows: [
...this.receivedRows.map((x) => [
x.instrument === 'FD' ? 'FD' : 'Govt Security / Bond',
x.dealNo,
x.particulars,
x.received
]),
[
'Total',
'',
'',
this.receivedTotal
]
]
}, },
{ {
sheetName: 'Pending Interest', sheetName: 'Pending Interest',
@@ -631,7 +667,7 @@ export class InvestmentReportsComponent implements OnInit {
case 'shiftingReport': case 'shiftingReport':
return one( return one(
'Shifting Report', 'Shifting Report',
'Shifting Report — Govt Securities transferred to another script / category', 'Shifting Report — securities transferred to another script / category',
[ [
'Date', 'Date',
'Deal No', 'Deal No',
+40 -7
View File
@@ -7,9 +7,10 @@
*/ */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpContext, HttpErrorResponse } from '@angular/common/http';
import { Observable, forkJoin } from 'rxjs'; import { Observable, forkJoin } from 'rxjs';
import { map, tap } from 'rxjs/operators'; import { map, tap } from 'rxjs/operators';
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
import { import {
DailyScriptRate, DailyScriptRate,
HoldingStatus, HoldingStatus,
@@ -23,6 +24,13 @@ import {
SlrType SlrType
} from './investment-data'; } from './investment-data';
/** Non-SLR (bonds, mutual funds): always AFS, no interest scheme; income only on sale / redemption (Jitendra Sir). */
export const NON_SLR_TYPES: string[] = [
'NON_SLR',
'BOND',
'MUTUAL_FUND'
];
/** Investment party as returned by /investment/parties. */ /** Investment party as returned by /investment/parties. */
export interface InvestmentPartyDto { export interface InvestmentPartyDto {
id: number; id: number;
@@ -511,8 +519,13 @@ export class InvestmentService {
); );
} }
createDailyRate(body: object): Observable<{ resourceId: number }> { /** `silent`: the caller shows the error itself (the rate file upload lists it against its row), so no dialog. */
return this.http.post<{ resourceId: number }>('/investment/daily-rates', body); createDailyRate(body: object, silent = false): Observable<{ resourceId: number }> {
return this.http.post<{ resourceId: number }>(
'/investment/daily-rates',
body,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
} }
deleteDailyRate(rateId: number): Observable<unknown> { deleteDailyRate(rateId: number): Observable<unknown> {
@@ -757,6 +770,17 @@ export function toHolding(p: InvestmentPurchaseDto): InvestmentHolding {
}; };
} }
/** Book value each deal has given up through settled sales, redemptions, write-offs and amortisation, by purchase id. */
export function bookValueSoldByPurchase(sales: InvestmentSaleDto[]): Map<number, number> {
const sold = new Map<number, number>();
for (const x of sales) {
if (x.status === 'SETTLED') {
sold.set(x.purchaseId, (sold.get(x.purchaseId) ?? 0) + (x.bookValueSold ?? 0));
}
}
return sold;
}
/** A security transfer shows the book value carried to the new security. */ /** A security transfer shows the book value carried to the new security. */
export function toTransferTxn(x: InvestmentSecurityTransferDto): InvestmentTxn { export function toTransferTxn(x: InvestmentSecurityTransferDto): InvestmentTxn {
return { return {
@@ -803,10 +827,11 @@ export function toSaleTxn(x: InvestmentSaleDto): InvestmentTxn {
}; };
} }
const redeem = x.saleType === 'REDEEM'; const redeem = x.saleType === 'REDEEM';
// A Govt Security redeemed below book writes off its premium; a non-SLR shortfall is a loss (500605).
const pl = const pl =
x.profitLoss >= 0 x.profitLoss >= 0
? `profit ₹${x.profitLoss}` ? `profit ₹${x.profitLoss}`
: redeem : redeem && !NON_SLR_TYPES.includes(x.bondType)
? `premium written off ₹${-x.profitLoss}` ? `premium written off ₹${-x.profitLoss}`
: `loss ₹${-x.profitLoss}`; : `loss ₹${-x.profitLoss}`;
return { return {
@@ -850,10 +875,11 @@ export function toPurchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn {
certNo: d.certNo, certNo: d.certNo,
instrumentType: d.bondType, instrumentType: d.bondType,
txnType: 'PURCHASE', txnType: 'PURCHASE',
voucherNo: `INV/PUR/${d.dealNo}`, // The settlement JV's transaction id; a pending deal has no voucher yet.
voucherNo: posted ? `TXN ${d.glTransactionId}` : `PENDING/${d.dealNo}`,
txnDate: d.settlementDate, txnDate: d.settlementDate,
amount: d.settlementAmount, amount: d.settlementAmount,
particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]`, particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]${posted ? '' : ' - PENDING SETTLEMENT'}`,
glCode: posted ? d.investmentGlCode : '', glCode: posted ? d.investmentGlCode : '',
glName: posted ? `${d.investmentGlName} (txn ${d.glTransactionId})` : 'Not posted (pending settlement)' glName: posted ? `${d.investmentGlName} (txn ${d.glTransactionId})` : 'Not posted (pending settlement)'
}; };
@@ -883,6 +909,8 @@ export function toFdHolding(f: InvestmentFdDto): InvestmentHolding {
crToAc: f.bankGlCode ?? '', crToAc: f.bankGlCode ?? '',
brokerCode: '', brokerCode: '',
status: f.status === 'ACTIVE' ? 'ACTIVE' : 'MATURED', status: f.status === 'ACTIVE' ? 'ACTIVE' : 'MATURED',
closureType: f.closureType,
closedOnDate: f.closedOnDate,
bookValue: f.principal bookValue: f.principal
}; };
} }
@@ -917,7 +945,12 @@ export function toFdTxns(f: InvestmentFdDto, rows: InvestmentFdInterestRowDto[])
if (f.closedOnDate) { if (f.closedOnDate) {
out.push({ out.push({
...base, ...base,
txnType: 'REDEEM', // The ledger's Type column shows how the FD ended: maturity, premature closure or renewal.
txnType: (f.closureType === 'PREMATURE'
? 'PREMATURE'
: f.closureType === 'RENEWAL'
? 'RENEWAL'
: 'REDEEM') as InvestmentTxn['txnType'],
voucherNo: f.closureVoucherNo ?? `FDC/${f.dealNo}`, voucherNo: f.closureVoucherNo ?? `FDC/${f.dealNo}`,
txnDate: f.closedOnDate, txnDate: f.closedOnDate,
amount: f.closureAmount ?? 0, amount: f.closureAmount ?? 0,