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:
@@ -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,11 +730,23 @@ export class BondInvestmentComponent implements OnInit {
|
||||
private suggestLastIntDate(): void {
|
||||
const f = this.purchaseForm;
|
||||
const s = this.purchaseScript;
|
||||
if (!this.isMutualFund && s?.maturityDate && f.dealDate) {
|
||||
f.lastIntDt = lastHalfYearlyCouponDate(s.maturityDate, f.dealDate) || f.lastIntDt;
|
||||
// The last coupon on or before SETTLEMENT (one falling between deal and settlement goes to the seller), or the
|
||||
// issue date when the security has not paid its first coupon yet — the same date the backend insists on.
|
||||
const settlement = this.purchaseSettlementDate;
|
||||
if (!this.isNonSlrPurchase && s?.maturityDate && settlement) {
|
||||
let last = lastHalfYearlyCouponDate(s.maturityDate, settlement);
|
||||
if (last && s.issueDate && parseDDMMYYYY(last) < parseDDMMYYYY(s.issueDate)) {
|
||||
last = s.issueDate;
|
||||
}
|
||||
f.lastIntDt = last || f.lastIntDt;
|
||||
}
|
||||
}
|
||||
|
||||
/** A different T+n moves the settlement date, and with it the last coupon the deal starts from. */
|
||||
onSettlementTypeChange(): void {
|
||||
this.suggestLastIntDate();
|
||||
}
|
||||
|
||||
/** Settlement Amt of Purchase — book value, accrued interest payable to the seller and the charges. */
|
||||
get purchaseSettlementAmount(): number {
|
||||
return this.purchaseBookValue + this.purchaseAccruedInterest + this.purchaseTotalCharges;
|
||||
@@ -817,7 +877,8 @@ export class BondInvestmentComponent implements OnInit {
|
||||
|
||||
/** Parties offered for a new deal — inactive parties are hidden. */
|
||||
get activePartyOptions(): typeof PARTY_MASTER {
|
||||
return this.partyOptions.filter((p) => p.status !== 'INACTIVE');
|
||||
// An FD bank is where deposits are placed, not a dealer securities are bought from or sold to.
|
||||
return this.partyOptions.filter((p) => p.status !== 'INACTIVE' && p.bondType !== 'FD');
|
||||
}
|
||||
|
||||
get purchaseInvestmentGlCode(): string {
|
||||
@@ -898,7 +959,8 @@ export class BondInvestmentComponent implements OnInit {
|
||||
} else {
|
||||
if (!f.faceValue) missing.push('Face Value');
|
||||
if (!f.purchaseRate) missing.push('Purchase Rate');
|
||||
if (!f.lastIntDt) missing.push('Last Interest Date');
|
||||
// Non-SLR has no interest scheme, so no last interest date (the field is hidden).
|
||||
if (!this.isNonSlrPurchase && !f.lastIntDt) missing.push('Last Interest Date');
|
||||
}
|
||||
if (!f.category) missing.push('Category');
|
||||
if (!f.crToAc) missing.push('CR. To A/c');
|
||||
@@ -926,7 +988,11 @@ export class BondInvestmentComponent implements OnInit {
|
||||
settlementType: f.settlementType,
|
||||
...(this.isMutualFund
|
||||
? { considerAmt: f.considerAmt, nav: f.nav }
|
||||
: { faceValue: f.faceValue, purchaseRate: f.purchaseRate, lastIntDate: f.lastIntDt }),
|
||||
: {
|
||||
faceValue: f.faceValue,
|
||||
purchaseRate: f.purchaseRate,
|
||||
...(this.isNonSlrPurchase ? {} : { lastIntDate: f.lastIntDt })
|
||||
}),
|
||||
stampDuty: f.stampDuty ?? null,
|
||||
brokerage: f.brokerage ?? null,
|
||||
otherCharges: f.otherCharges ?? null,
|
||||
@@ -1095,14 +1161,24 @@ export class BondInvestmentComponent implements OnInit {
|
||||
redeeming = false;
|
||||
|
||||
/** Settled holdings of this type still held — the ones that can be sold or redeemed. */
|
||||
/** A mutual fund is sold / redeemed at its NAV per unit, not at a price per ₹100. */
|
||||
get saleIsMutualFund(): boolean {
|
||||
return this.holdings.find((h) => h.id === this.sale.purchaseId)?.instrumentType === 'MUTUAL_FUND';
|
||||
}
|
||||
|
||||
get saleableHoldings(): InvestmentHolding[] {
|
||||
return this.activeHoldingsForType.filter((h) => !!h.id);
|
||||
}
|
||||
|
||||
/** Redemption is at maturity, so only holdings that have reached it are offered. */
|
||||
/**
|
||||
* Redemption is at maturity, so only holdings that have reached it are offered. A mutual fund has no maturity: it
|
||||
* is redeemed at its NAV through the Sell entry.
|
||||
*/
|
||||
get redeemableHoldings(): InvestmentHolding[] {
|
||||
const today = parseDDMMYYYY(this.asOnDate);
|
||||
return this.saleableHoldings.filter((h) => !!h.maturityDate && parseDDMMYYYY(h.maturityDate) <= today);
|
||||
return this.saleableHoldings.filter(
|
||||
(h) => h.instrumentType !== 'MUTUAL_FUND' && !!h.maturityDate && parseDDMMYYYY(h.maturityDate) <= today
|
||||
);
|
||||
}
|
||||
|
||||
/** Shows what the redemption will post, or why it cannot be done yet (before maturity, coupon not posted). */
|
||||
@@ -1158,6 +1234,7 @@ export class BondInvestmentComponent implements OnInit {
|
||||
(h) =>
|
||||
h.id != null &&
|
||||
h.status === 'ACTIVE' &&
|
||||
h.instrumentType === 'GOVT_SECURITY' &&
|
||||
(!this.intType || h.instrumentType === this.intType) &&
|
||||
(!this.intScript || h.scriptCode === this.intScript)
|
||||
);
|
||||
@@ -1257,7 +1334,7 @@ export class BondInvestmentComponent implements OnInit {
|
||||
|
||||
/** Total of the lines falling due up to the as-on date. */
|
||||
get intDueTotal(): number {
|
||||
return (this.intSchedule?.rows ?? []).filter((r) => r.due).reduce((sum, r) => sum + r.amount, 0);
|
||||
return (this.intSchedule?.rows ?? []).filter((r) => r.due && !r.posted).reduce((sum, r) => sum + r.amount, 0);
|
||||
}
|
||||
|
||||
// -------------------- Sell Entry --------------------
|
||||
@@ -1310,8 +1387,12 @@ export class BondInvestmentComponent implements OnInit {
|
||||
}
|
||||
|
||||
/** Price, interest from the buyer, cost and profit / loss worked out by the backend, as Settle will post them. */
|
||||
/** Each quote request is numbered so a slower, older response cannot overwrite the latest one. */
|
||||
private saleQuoteSeq = 0;
|
||||
|
||||
refreshSaleQuote(): void {
|
||||
const f = this.sale;
|
||||
const seq = ++this.saleQuoteSeq;
|
||||
this.saleQuote = null;
|
||||
this.saleQuoteError = '';
|
||||
if (!f.purchaseId || !f.units || !f.saleRate || !f.dealDate) {
|
||||
@@ -1329,8 +1410,16 @@ export class BondInvestmentComponent implements OnInit {
|
||||
...(bankGlAccountId ? { bankGlAccountId } : {})
|
||||
})
|
||||
.subscribe({
|
||||
next: (q) => (this.saleQuote = q),
|
||||
error: (err) => (this.saleQuoteError = apiErrorMessage(err))
|
||||
next: (q) => {
|
||||
if (seq === this.saleQuoteSeq) {
|
||||
this.saleQuote = q;
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
if (seq === this.saleQuoteSeq) {
|
||||
this.saleQuoteError = apiErrorMessage(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1434,6 +1523,11 @@ export class BondInvestmentComponent implements OnInit {
|
||||
return !!q.premiumCleared && q.premiumCleared > 0 && !!q.premiumGlCode && q.premiumGlCode !== q.investmentGlCode;
|
||||
}
|
||||
|
||||
/** Non-SLR gain on sale / redemption is its income, 400402; a shortfall is a loss, 500605 (Jitendra Sir). */
|
||||
isNonSlrSale(q: InvestmentSaleDto): boolean {
|
||||
return NON_SLR_TYPES.includes(q.bondType);
|
||||
}
|
||||
|
||||
/** Book value of a holding as it stands: cost less what sales and write-offs have taken off. */
|
||||
carryingValue(h: InvestmentHolding): number {
|
||||
const taken = this.sales
|
||||
@@ -1573,6 +1667,11 @@ export class BondInvestmentComponent implements OnInit {
|
||||
goiRemarks = '';
|
||||
goiSaving = false;
|
||||
|
||||
/** Only Govt Security holdings can be transferred (the backend refuses bonds, which would skip the non-SLR checks). */
|
||||
get goiTransferHoldings(): InvestmentHolding[] {
|
||||
return this.saleableHoldings.filter((h) => h.instrumentType === 'GOVT_SECURITY');
|
||||
}
|
||||
|
||||
get goiHolding(): InvestmentHolding | undefined {
|
||||
return this.holdings.find((h) => h.id === this.goiPurchaseId);
|
||||
}
|
||||
@@ -1763,6 +1862,10 @@ export class BondInvestmentComponent implements OnInit {
|
||||
* Coupons fall every six months from the purchase's last interest date; a coupon received resets it to zero.
|
||||
*/
|
||||
accrued(h: InvestmentHolding): number {
|
||||
// Non-SLR has no interest scheme: nothing accrues.
|
||||
if (NON_SLR_TYPES.includes(h.instrumentType)) {
|
||||
return 0;
|
||||
}
|
||||
if (!h.lastIntDate || h.status !== 'ACTIVE') {
|
||||
return h.lastIntDate ? 0 : accruedInterest(h, this.asOnDate);
|
||||
}
|
||||
@@ -1774,8 +1877,11 @@ export class BondInvestmentComponent implements OnInit {
|
||||
return Math.round((h.faceValue * h.interestRate * days360(lastCoupon, asOn)) / 360) / 100;
|
||||
}
|
||||
|
||||
/** A fund or share has no maturity: it comes back at the book value still held, as Reports and the Dashboard show. */
|
||||
maturity(h: InvestmentHolding): number {
|
||||
return maturityAmount(h);
|
||||
return h.instrumentType === 'MUTUAL_FUND' || h.instrumentType === 'SHARE'
|
||||
? this.carryingValue(h)
|
||||
: maturityAmount(h);
|
||||
}
|
||||
|
||||
daysLeft(h: InvestmentHolding): number {
|
||||
@@ -1833,7 +1939,7 @@ export class BondInvestmentComponent implements OnInit {
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.purchaseDate,
|
||||
h.maturityDate,
|
||||
h.instrumentType === 'MUTUAL_FUND' ? '' : h.maturityDate,
|
||||
this.accrued(h),
|
||||
this.maturity(h),
|
||||
h.status
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user