From 13d04353e1f614153d250c6cb7cbcec5e3a2bb35 Mon Sep 17 00:00:00 2001 From: anshul Date: Tue, 27 Oct 2026 11:14:41 +0530 Subject: [PATCH] feat(investment): non-SLR bonds and mutual funds, rate upload, UAT fixes Co-Authored-By: Claude Opus 5.5 --- src/app/home/home.component.html | 4 +- .../bond-investment.component.html | 41 +++-- .../bond-investment.component.ts | 148 +++++++++++++++--- .../fd-investment.component.html | 7 +- .../fd-investment/fd-investment.component.ts | 6 + .../investment-dashboard.component.ts | 18 ++- src/app/investment/investment-data.ts | 7 +- .../investment-diary.component.ts | 2 +- .../investment-masters.component.html | 38 +++-- .../investment-masters.component.ts | 125 ++++++++++++++- .../investment-reports.component.html | 56 +++++-- .../investment-reports.component.ts | 98 ++++++++---- src/app/investment/investment.service.ts | 47 +++++- 13 files changed, 486 insertions(+), 111 deletions(-) diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 878300434..c66a0e09d 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -56,7 +56,7 @@ @if (pendingInvestmentInterest.length) {
GOI / Bonds: {{ pendingInvestmentInterest.length }} interest voucher(s){{ + >GOI / Bonds: {{ pendingInvestmentInterest.length }} holding(s) with interest pending{{ overdueNote(pendingInvestmentInterest) }} @@ -82,7 +82,7 @@ @if (pendingFd.length) {
FD with banks: {{ pendingFd.length }} entry(ies){{ overdueNote(pendingFd) }}FD with banks: {{ pendingFd.length }} FD(s) with entries pending{{ overdueNote(pendingFd) }} - } @else { + } @else if (isDueSoon(r.intDate)) { 🔔 Due {{ r.intDate }} + } @else { + Scheduled {{ r.intDate }} } diff --git a/src/app/investment/fd-investment/fd-investment.component.ts b/src/app/investment/fd-investment/fd-investment.component.ts index e80a64160..d3096fede 100644 --- a/src/app/investment/fd-investment/fd-investment.component.ts +++ b/src/app/investment/fd-investment/fd-investment.component.ts @@ -137,6 +137,12 @@ export class FdInvestmentComponent implements OnInit { toastVisible = false; private toastTimer: ReturnType | 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)), diff --git a/src/app/investment/investment-dashboard/investment-dashboard.component.ts b/src/app/investment/investment-dashboard/investment-dashboard.component.ts index bbfc8c4d3..ab390321b 100644 --- a/src/app/investment/investment-dashboard/investment-dashboard.component.ts +++ b/src/app/investment/investment-dashboard/investment-dashboard.component.ts @@ -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[] = [ diff --git a/src/app/investment/investment-data.ts b/src/app/investment/investment-data.ts index 05df45bee..a9d718630 100644 --- a/src/app/investment/investment-data.ts +++ b/src/app/investment/investment-data.ts @@ -29,7 +29,9 @@ export type TxnType = | 'WRITE_OFF' | 'AMORTIZE' | 'TRANSFER' - | 'PART_PAYMENT'; + | 'PART_PAYMENT' + | 'PREMATURE' + | 'RENEWAL'; export const INSTRUMENT_LABELS: Record = { 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. */ diff --git a/src/app/investment/investment-diary/investment-diary.component.ts b/src/app/investment/investment-diary/investment-diary.component.ts index 55a1bab20..79723f21a 100644 --- a/src/app/investment/investment-diary/investment-diary.component.ts +++ b/src/app/investment/investment-diary/investment-diary.component.ts @@ -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 { diff --git a/src/app/investment/investment-masters/investment-masters.component.html b/src/app/investment/investment-masters/investment-masters.component.html index 0565cf7b4..6911a02e9 100644 --- a/src/app/investment/investment-masters/investment-masters.component.html +++ b/src/app/investment/investment-masters/investment-masters.component.html @@ -301,6 +301,10 @@ +
+
+

+ 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. +

+ @if (uploadResults.length) { +
+ @for (r of uploadResults; track r.row) { +

Row {{ r.row }}: {{ r.text }}

+ } +
+ } @@ -640,10 +659,11 @@ {{ r.scriptCode }} - - - - + + + + + diff --git a/src/app/investment/investment-masters/investment-masters.component.ts b/src/app/investment/investment-masters/investment-masters.component.ts index 13c2f137c..c706aa571 100644 --- a/src/app/investment/investment-masters/investment-masters.component.ts +++ b/src/app/investment/investment-masters/investment-masters.component.ts @@ -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 { + 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 { + 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) { diff --git a/src/app/investment/investment-reports/investment-reports.component.html b/src/app/investment/investment-reports/investment-reports.component.html index 29c989aee..b3a502cbb 100644 --- a/src/app/investment/investment-reports/investment-reports.component.html +++ b/src/app/investment/investment-reports/investment-reports.component.html @@ -122,7 +122,8 @@ - + + @@ -142,14 +143,21 @@ - + + } @empty { - @@ -160,7 +168,7 @@ } @if (activeReport === 'bankInvList') {
- +
{{ r.scriptName }}{{ r.openRate | number: '1.2-2' }}{{ r.highRate | number: '1.2-2' }}{{ r.lowRate | number: '1.2-2' }}{{ r.closeRate | number: '1.2-2' }}{{ r.openRate | number: '1.2-4' }}{{ r.highRate | number: '1.2-4' }}{{ r.lowRate | number: '1.2-4' }}{{ r.closeRate | number: '1.2-4' }} Script / Party Units Face ValueMarket/Purchase RateBook ValueRate Status
{{ h.scriptName }} — {{ h.party }} {{ h.units | number }} ₹{{ h.faceValue | number: '1.2-2' }}{{ marketPrice(h.scriptCode) || h.purchaseRate }}₹{{ h.bookValue ?? h.faceValue | number: '1.2-2' }} + @if (marketPrice(h.scriptCode); as mp) { + {{ mp }} (market {{ marketRateDate(h.scriptCode) }}) + } @else { + {{ h.purchaseRate }} (purchase) + } + {{ h.status }}
+ {{ activeReport === 'shareBalance' ? 'Share entry is not live yet.' : 'No holdings.' }}
@@ -383,30 +391,42 @@ } @if (activeReport === 'pendingReceived') {
- +
- - + + - + - @for (t of receivedInterestTxns; track $index) { + @for (x of receivedRows; track x.instrument + ':' + x.refId) { - - - - + + + + } @empty { - + } + @if (receivedRows.length) { + + + + + + + }
DateCert NoInvestmentDeal No ParticularsAmountReceived
{{ t.txnDate }}{{ t.certNo }}{{ t.particulars }}₹{{ t.amount | number: '1.2-2' }}{{ x.instrument === 'FD' ? 'FD' : 'Govt Security / Bond' }} + {{ x.dealNo }} + {{ x.particulars }}₹{{ x.received | number: '1.2-2' }}
No interest received yet.No interest received in this period.
Total₹{{ receivedTotal | number: '1.2-2' }}
@@ -514,6 +534,8 @@ Party Face Value Maturity Date + Closed As + Closed On @@ -525,10 +547,12 @@ {{ h.party }} ₹{{ h.faceValue | number: '1.2-2' }} {{ h.maturityDate }} + {{ h.closureType || '—' }} + {{ h.closedOnDate || '—' }} } @empty { - No matured FDs. + No matured FDs. } @@ -642,7 +666,7 @@ } @if (activeReport === 'shiftingReport') {
- + diff --git a/src/app/investment/investment-reports/investment-reports.component.ts b/src/app/investment/investment-reports/investment-reports.component.ts index 080227b49..433b22a37 100644 --- a/src/app/investment/investment-reports/investment-reports.component.ts +++ b/src/app/investment/investment-reports/investment-reports.component.ts @@ -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(); private fdMaturity = new Map(); private latestRate = new Map(); + private latestRateDate = new Map(); 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(); - 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', diff --git a/src/app/investment/investment.service.ts b/src/app/investment/investment.service.ts index 95c582551..5eb2b2889 100644 --- a/src/app/investment/investment.service.ts +++ b/src/app/investment/investment.service.ts @@ -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 { @@ -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 { + const sold = new Map(); + 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,