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) {
<div class="iia-row">
<span class="iia-summary"
>GOI / Bonds: {{ pendingInvestmentInterest.length }} interest voucher(s){{
>GOI / Bonds: {{ pendingInvestmentInterest.length }} holding(s) with interest pending{{
overdueNote(pendingInvestmentInterest)
}}</span
>
@@ -82,7 +82,7 @@
@if (pendingFd.length) {
<div class="iia-row">
<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">
{{ showFdDetails ? 'Hide' : 'Details' }}
@@ -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,9 +730,21 @@ 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. */
@@ -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
@@ -254,7 +254,8 @@
/></label>
@if (q.payout !== 'MATURITY') {
<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
/></label>
}
@@ -415,8 +416,10 @@
>
Generate Voucher
</button>
} @else {
} @else if (isDueSoon(r.intDate)) {
🔔 Due {{ r.intDate }}
} @else {
Scheduled {{ r.intDate }}
}
</td>
</tr>
@@ -137,6 +137,12 @@ export class FdInvestmentComponent implements OnInit {
toastVisible = false;
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 {
this.investmentService.getParties().subscribe({
next: (ps) => (this.fdParties = ps.filter((p) => p.bondType === 'FD' && p.active && !!p.interestGlAccountId)),
@@ -20,8 +20,10 @@ import {
InvestmentInterestReportDto,
InvestmentNonSlrLimitDto,
InvestmentPurchaseDto,
InvestmentSaleDto,
InvestmentService,
apiErrorMessage
apiErrorMessage,
bookValueSoldByPurchase
} from '../investment.service';
interface InstrumentSummary {
@@ -114,12 +116,13 @@ export class InvestmentDashboardComponent implements OnInit {
bucket: this.investmentService.getBucketReport(today),
interest: this.investmentService.getInterestReport(fyStart, today),
purchases: this.investmentService.getPurchases(),
sales: this.investmentService.getSales(),
fds: this.investmentService.getFds(),
fdQuote: this.investmentService.quoteFd({ amount: 0, intRate: 0, dealDate: today }),
nonSlr: this.investmentService.getNonSlrLimit(null, today, 0)
}).subscribe({
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;
},
error: (err: unknown) => {
@@ -133,6 +136,7 @@ export class InvestmentDashboardComponent implements OnInit {
bucket: InvestmentBucketReportDto,
interest: InvestmentInterestReportDto,
purchases: InvestmentPurchaseDto[],
sales: InvestmentSaleDto[],
fds: InvestmentFdDto[],
fdLimit: InvestmentFdLimitDto | null,
nonSlr: InvestmentNonSlrLimitDto
@@ -192,14 +196,16 @@ export class InvestmentDashboardComponent implements OnInit {
s.maturityValue +=
r.instrument === 'FD' ? (fdById.get(r.refId)?.expectedMaturityAmount ?? r.faceValue) : r.faceValue;
}
// Mutual funds and shares have no maturity, so they are not in the buckets; their units are held at cost.
// 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) {
if ((p.bondType === 'MUTUAL_FUND' || p.bondType === 'SHARE') && p.status === 'ACTIVE') {
const s = sum(p.bondType);
const book = p.bookValue - (sold.get(p.id) ?? 0);
s.count += 1;
s.faceValue += p.faceValue;
s.bookValue += p.bookValue;
s.maturityValue += p.bookValue;
s.faceValue += p.units ? (p.faceValue * p.balanceUnits) / p.units : p.faceValue;
s.bookValue += book;
s.maturityValue += book;
}
}
const order: InstrumentType[] = [
+6 -1
View File
@@ -29,7 +29,9 @@ export type TxnType =
| 'WRITE_OFF'
| 'AMORTIZE'
| 'TRANSFER'
| 'PART_PAYMENT';
| 'PART_PAYMENT'
| 'PREMATURE'
| 'RENEWAL';
export const INSTRUMENT_LABELS: Record<InstrumentType, string> = {
FD: 'Fixed Deposit',
@@ -134,6 +136,9 @@ export interface InvestmentHolding {
partyCode?: string;
bookValue?: 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. */
settlementDate?: string;
/** 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 } };
}
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 {
@@ -301,6 +301,10 @@
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}ISIN</span><input type="text" [(ngModel)]="scriptForm.isin"
/></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"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Int Rate %</span
><input type="number" step="0.01" min="0" [(ngModel)]="scriptForm.couponRate"
@@ -364,7 +368,7 @@
{{
scriptRatingEligible
? '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>
}
@@ -604,21 +608,36 @@
</select>
</label>
<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 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 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 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>
</div>
<div class="inv-form-actions">
<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>
<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">
<thead>
<tr>
@@ -640,10 +659,11 @@
<span class="pg-chip">{{ r.scriptCode }}</span>
</td>
<td>{{ r.scriptName }}</td>
<td class="num">{{ r.openRate | number: '1.2-2' }}</td>
<td class="num">{{ r.highRate | number: '1.2-2' }}</td>
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td>
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td>
<!-- Mutual fund NAVs run to 4 decimals. -->
<td class="num">{{ r.openRate | number: '1.2-4' }}</td>
<td class="num">{{ r.highRate | number: '1.2-4' }}</td>
<td class="num">{{ r.lowRate | number: '1.2-4' }}</td>
<td class="num">{{ r.closeRate | number: '1.2-4' }}</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropDailyRate(r)">Drop</button>
</td>
@@ -9,6 +9,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
BROKER_MASTER,
@@ -33,6 +34,7 @@ import {
SLR_TYPE_OPTIONS,
INVESTMENT_GL_LOOKUP,
ELIGIBLE_RATINGS,
parseDDMMYYYY,
RATING_AGENCY_OPTIONS,
RATING_OPTIONS,
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. */
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. */
@@ -361,6 +375,8 @@ export class InvestmentMastersComponent implements OnInit {
glAccountId: Number(f.glAccountId),
isin: f.isin ?? '',
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 ?? '',
maturityDate: f.maturityDate ?? '',
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. */
dropDailyRate(r: DailyScriptRate): void {
if (!r.id) {
@@ -122,7 +122,8 @@
<th>Script / Party</th>
<th class="num">Units</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>
</tr>
</thead>
@@ -142,14 +143,21 @@
<td>{{ h.scriptName }} — {{ h.party }}</td>
<td class="num">{{ h.units | number }}</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>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">
<td colspan="7" class="pg-empty">
{{ activeReport === 'shareBalance' ? 'Share entry is not live yet.' : 'No holdings.' }}
</td>
</tr>
@@ -160,7 +168,7 @@
}
@if (activeReport === 'bankInvList') {
<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">
<thead>
<tr>
@@ -383,30 +391,42 @@
}
@if (activeReport === 'pendingReceived') {
<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">
<thead>
<tr>
<th>Date</th>
<th>Cert No</th>
<th>Investment</th>
<th>Deal No</th>
<th>Particulars</th>
<th class="num col-in">Amount</th>
<th class="num col-in">Received</th>
</tr>
</thead>
<tbody>
@for (t of receivedInterestTxns; track $index) {
@for (x of receivedRows; track x.instrument + ':' + x.refId) {
<tr>
<td>{{ t.txnDate }}</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
<td>{{ x.instrument === 'FD' ? 'FD' : 'Govt Security / Bond' }}</td>
<td>
<span class="pg-chip">{{ x.dealNo }}</span>
</td>
<td>{{ x.particulars }}</td>
<td class="num col-in">₹{{ x.received | number: '1.2-2' }}</td>
</tr>
} @empty {
<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>
}
</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>
</div>
<div class="pg-card">
@@ -514,6 +534,8 @@
<th>Party</th>
<th class="num">Face Value</th>
<th>Maturity Date</th>
<th>Closed As</th>
<th>Closed On</th>
</tr>
</thead>
<tbody>
@@ -525,10 +547,12 @@
<td>{{ h.party }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td>{{ h.maturityDate }}</td>
<td>{{ h.closureType || '—' }}</td>
<td>{{ h.closedOnDate || '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="pg-empty">No matured FDs.</td>
<td colspan="6" class="pg-empty">No matured FDs.</td>
</tr>
}
</tbody>
@@ -642,7 +666,7 @@
}
@if (activeReport === 'shiftingReport') {
<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">
<thead>
<tr>
@@ -25,9 +25,11 @@ import {
InvestmentDailyRateDto,
InvestmentFdDto,
InvestmentFdScheduleDto,
InvestmentInterestReportRowDto,
InvestmentSecurityTransferDto,
InvestmentService,
apiErrorMessage,
bookValueSoldByPurchase,
toFdHolding,
toFdTxns,
toHolding,
@@ -75,8 +77,11 @@ export class InvestmentReportsComponent implements OnInit {
/** Saved purchases and FDs as holdings, and their GL entries, newest first. */
holdings: InvestmentHolding[] = [];
txns: InvestmentTxn[] = [];
/** Coupons and FD interest paid out — interest actually received. */
receivedTxns: InvestmentTxn[] = [];
/** Interest received in the financial year to the as-on date, per deal / FD (Interest Received & Receivable report). */
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). */
transfers: InvestmentSecurityTransferDto[] = [];
/** 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 fdMaturity = new Map<number, number>();
private latestRate = new Map<string, number>();
private latestRateDate = new Map<string, string>();
ngOnInit(): void {
this.load();
@@ -108,18 +114,24 @@ export class InvestmentReportsComponent implements OnInit {
next: (r) => {
this.fds = r.fds;
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 = [
...r.purchases.map(toHolding),
...r.purchases.map((p) => ({ ...toHolding(p), bookValue: p.bookValue - (sold.get(p.id) ?? 0) })),
...r.fds.map(toFdHolding)
];
// Ledgers show what reached the GL: settled purchases and sales only.
this.txns = this.byDateDesc([
...r.purchases.map(toPurchaseTxn),
...r.purchases.filter((p) => !!p.glTransactionId).map(toPurchaseTxn),
...r.postings.map(toInterestTxn),
...r.sales.map(toSaleTxn),
...r.sales.filter((x) => x.status === 'SETTLED').map(toSaleTxn),
...r.transfers.map(toTransferTxn),
...r.fds.flatMap((f) => toFdTxns(f, []))
]);
this.receivedTxns = this.byDateDesc(r.postings.filter((x) => x.lineType === 'COUPON').map(toInterestTxn));
this.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(
r.interest.rows.map((x) => [
x.instrument + ':' + x.refId,
@@ -140,9 +152,12 @@ export class InvestmentReportsComponent implements OnInit {
[...r.rates]
.filter((x) => parseDDMMYYYY(x.rateDate).getTime() <= asOn)
.sort((a, b) => parseDDMMYYYY(a.rateDate).getTime() - parseDDMMYYYY(b.rateDate).getTime())
.forEach((x) => this.latestRate.set(x.scriptCode, x.closeRate));
.forEach((x) => {
this.latestRate.set(x.scriptCode, x.closeRate);
this.latestRateDate.set(x.scriptCode, x.rateDate);
});
this.loading = false;
if (this.activeReport === 'fdLedger' || this.activeReport === 'pendingReceived') {
if (this.activeReport === 'fdLedger') {
this.loadFdInterest();
}
},
@@ -168,23 +183,15 @@ export class InvestmentReportsComponent implements OnInit {
)
).subscribe((schedules) => {
const lines: InvestmentTxn[] = [];
const received: InvestmentTxn[] = [];
schedules.forEach((s, i) => {
if (!s) {
return;
if (s) {
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,
...lines
]);
this.receivedTxns = this.byDateDesc([
...this.receivedTxns,
...received
]);
});
}
@@ -201,7 +208,7 @@ export class InvestmentReportsComponent implements OnInit {
setReport(report: string): void {
this.activeReport = report;
if (report === 'fdLedger' || report === 'pendingReceived') {
if (report === 'fdLedger') {
this.loadFdInterest();
}
}
@@ -238,6 +245,11 @@ export class InvestmentReportsComponent implements OnInit {
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[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && (!type || h.instrumentType === type));
}
@@ -256,9 +268,10 @@ export class InvestmentReportsComponent implements OnInit {
get fdHoldings(): InvestmentHolding[] {
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 }> {
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 };
row.count += 1;
row.totalFaceValue += h.faceValue;
@@ -286,7 +299,7 @@ export class InvestmentReportsComponent implements OnInit {
return this.txns.filter((t) => t.txnType === 'AMORTIZE');
}
get dealSlips(): InvestmentTxn[] {
return this.txns.filter((t) => t.txnType === 'PURCHASE' && t.instrumentType !== 'FD');
return this.dealSlipTxns;
}
get slrBondRegister(): InvestmentHolding[] {
return this.active('GOVT_SECURITY');
@@ -297,8 +310,8 @@ export class InvestmentReportsComponent implements OnInit {
.map((h) => ({ ...h, dueAmount: this.accrued(h) }))
.filter((h) => h.dueAmount > 0);
}
get receivedInterestTxns(): InvestmentTxn[] {
return this.receivedTxns;
get receivedTotal(): number {
return this.receivedRows.reduce((t, x) => t + x.received, 0);
}
get pendingInterestHoldings(): InvestmentHolding[] {
return this.active().filter((h) => this.accrued(h) > 0);
@@ -406,7 +419,7 @@ export class InvestmentReportsComponent implements OnInit {
h.purchaseRate,
h.interestRate,
h.purchaseDate,
h.maturityDate,
h.instrumentType === 'MUTUAL_FUND' ? '' : h.maturityDate,
this.accrued(h),
this.maturity(h),
h.status
@@ -539,7 +552,7 @@ export class InvestmentReportsComponent implements OnInit {
case 'intDueRegister':
return one(
'Interest Due Register',
'Interest Due Register (Non-Cumulative Holdings)',
'Interest Due Register (interest earned, not yet received)',
[
...this.holdingsExportHeaders,
'Due Amount'
@@ -565,11 +578,34 @@ export class InvestmentReportsComponent implements OnInit {
return [
{
sheetName: 'Received Interest',
title: 'Received Interest / Dividend',
subtitle,
headers: this.txnExportHeaders,
widths: this.txnExportWidths,
rows: this.receivedInterestTxns.map((t) => this.txnExportRow(t))
title: 'Interest Received',
subtitle: `${this.receivedFrom} to ${this.asOnDate}`,
headers: [
'Investment',
'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',
@@ -631,7 +667,7 @@ export class InvestmentReportsComponent implements OnInit {
case 'shiftingReport':
return one(
'Shifting Report',
'Shifting Report — Govt Securities transferred to another script / category',
'Shifting Report — securities transferred to another script / category',
[
'Date',
'Deal No',
+40 -7
View File
@@ -7,9 +7,10 @@
*/
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 { map, tap } from 'rxjs/operators';
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
import {
DailyScriptRate,
HoldingStatus,
@@ -23,6 +24,13 @@ import {
SlrType
} 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. */
export interface InvestmentPartyDto {
id: number;
@@ -511,8 +519,13 @@ export class InvestmentService {
);
}
createDailyRate(body: object): Observable<{ resourceId: number }> {
return this.http.post<{ resourceId: number }>('/investment/daily-rates', body);
/** `silent`: the caller shows the error itself (the rate file upload lists it against its row), so no dialog. */
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> {
@@ -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. */
export function toTransferTxn(x: InvestmentSecurityTransferDto): InvestmentTxn {
return {
@@ -803,10 +827,11 @@ export function toSaleTxn(x: InvestmentSaleDto): InvestmentTxn {
};
}
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 =
x.profitLoss >= 0
? `profit ₹${x.profitLoss}`
: redeem
: redeem && !NON_SLR_TYPES.includes(x.bondType)
? `premium written off ₹${-x.profitLoss}`
: `loss ₹${-x.profitLoss}`;
return {
@@ -850,10 +875,11 @@ export function toPurchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn {
certNo: d.certNo,
instrumentType: d.bondType,
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,
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 : '',
glName: posted ? `${d.investmentGlName} (txn ${d.glTransactionId})` : 'Not posted (pending settlement)'
};
@@ -883,6 +909,8 @@ export function toFdHolding(f: InvestmentFdDto): InvestmentHolding {
crToAc: f.bankGlCode ?? '',
brokerCode: '',
status: f.status === 'ACTIVE' ? 'ACTIVE' : 'MATURED',
closureType: f.closureType,
closedOnDate: f.closedOnDate,
bookValue: f.principal
};
}
@@ -917,7 +945,12 @@ export function toFdTxns(f: InvestmentFdDto, rows: InvestmentFdInterestRowDto[])
if (f.closedOnDate) {
out.push({
...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}`,
txnDate: f.closedOnDate,
amount: f.closureAmount ?? 0,