/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component'; import { SCRIPT_MASTER, PARTY_MASTER, SUBTYPE_MASTER, BROKER_MASTER, SGL_BANK_MASTER, MUTUAL_FUND_PRODUCTS, LIEN_REASON_MASTER, CATEGORY_OPTIONS, DEAL_TYPE_OPTIONS, MARKET_OPTIONS, PAYMENT_MODE_OPTIONS, DELIVERY_MODE_OPTIONS, BOND_RATING_OPTIONS, INT_TYPE_OPTIONS, AUTHORITY_OPTIONS, INVESTMENT_GL_BY_TYPE, INVESTMENT_GL_LOOKUP, INTEREST_RECEIVABLE_GL, BROKEN_PERIOD_INTEREST_GL, STAMP_DUTY_GL, BROKERAGE_GL, OTHER_CHARGES_GL, SETTLEMENT_BANK_GL_OPTIONS, periodBetween, parseDDMMYYYY, days360, lastHalfYearlyCouponDate, nextHalfYearlyCouponDate, couponDatesLabel, InvestmentHolding, InvestmentTxn, InstrumentType, InterestFreq, INSTRUMENT_LABELS, accruedInterest, daysToMaturity, maturityAmount, addPeriod, approxYtm, yearsBetween, todayDDMMYYYY } from '../investment-data'; import { exportWorkbook } from '../investment-export'; import { InvestmentDailyRateDto, InvestmentInterestDueDto, InvestmentInterestRowDto, InvestmentInterestScheduleDto, InvestmentPurchaseDto, InvestmentService, apiErrorMessage, toHolding } from '../investment.service'; type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND'; type BondTab = | 'holdings' | 'purchase' | 'redemption' | 'interest' | 'sell' | 'writeoff' | 'amortize' | 'amortizeYearly' | 'goiTransfer' | 'depreciation' | 'mfTransfer'; interface BondPurchaseForm { bondType: BondType; dealNo: string; dealDate: string; dealTime: string; isin: string; scriptCode: string; certNo: string; settlementType: 'T+0' | 'T+1' | 'T+2'; settlementDt: string; faceValue: number | null; purchaseRate: number | null; partyCode: string; subType: string; tbillGlAc: string; crToAc: string; premiumAmt: number | null; brokerage: number | null; brokerName: string; tradeNo: string; orderNo: string; recommAuthority: string; sanctionAuthority: string; issueDate: string; periodYear: number | null; periodMonth: number | null; periodDays: number | null; descInv: string; folioNo: string; callOption: string; meetDt: string; resoNo: string; fromDist: string; toDist: string; redumMatDt: string; category: string; intRate: number | null; intType: InterestFreq; intDate: string; lastIntDt: string; dealType: string; market: string; markCblo: 'YES' | 'NO'; amortizeAmt: number | null; remarks: string; lien: 'YES' | 'NO'; lienAmount: number | null; lienReason: string; deliveryMode: string; bondRating: string; corpBond: 'YES' | 'NO'; paymentMode: string; sglWithBank: string; description: string; registerAdd: string; regiCity: string; regiPin: string; // Mutual Fund specific productName: string; considerAmt: number | null; tradeAmt: number | null; nav: number | null; holdingDays: number | null; stampDuty: number | null; stampDutyAc: string; otherCharges: number | null; } interface AccountingPreviewLine { side: 'Dr' | 'Cr'; glCode: string; glName: string; amount: number; } interface BondSellForm { /** Legacy screen has SELL / REDEEM / MUTUAL FUND SELL radio at the top. */ mode: 'SELL' | 'REDEEM' | 'MUTUAL_FUND_SELL'; /** Purchase Search — locate the original purchase by Deal No or ISIN No. */ searchBy: 'DEAL_NO' | 'ISIN_NO'; searchValue: string; bondType: BondType; scriptCode: string; certNo: string; buyerCode: string; subType: string; isin: string; tbillGlAc: string; dealDate: string; dealTime: string; settlementType: 'T+0' | 'T+1' | 'T+2'; settlementDt: string; faceValue: number | null; sellRate: number | null; tdsAmt: number | null; unitVal: number | null; payAmt: number | null; sellUnit: number | null; brokerage: number | null; dbToAc: string; amortizeAmt: number | null; recommAuthority: string; sanctionAuthority: string; callMoney: boolean; } /** * Combined Govt Security / Non-SLR / Bond / Mutual Fund treasury screen, mirroring the legacy * "03 Govt./Bond Entry" submenu in full: Purchase, Redemption, Interest Calculation, Sell, * Write-off, Amortize, Amortize Yearly Process, GOI Category Transfer, Investment Depreciation * and Mutual Fund Transfer. Dummy in-memory data; Save appends to the local arrays. */ @Component({ selector: 'mifosx-bond-investment', templateUrl: './bond-investment.component.html', styleUrls: [ '../../cashier/cashier-shared.scss', '../investment-shared.scss' ], standalone: true, imports: [ CommonModule, FormsModule, DateDdmmyyyyPickerComponent ] }) export class BondInvestmentComponent implements OnInit { private readonly investmentService = inject(InvestmentService); private readonly route = inject(ActivatedRoute); /** Script and party dropdowns offer the masters saved in the backend. */ ngOnInit(): void { // The home-screen interest alert links here with ?tab=interest. if (this.route.snapshot.queryParamMap.get('tab') === 'interest') { this.activeTab = 'interest'; } this.investmentService.syncSharedMasters().subscribe({ error: (err) => this.toast(`Could not load Script / Party masters: ${apiErrorMessage(err)}`) }); this.investmentService.getAssetGlAccounts().subscribe({ next: (gls) => (this.glIdByCode = new Map( gls.map((g) => [ g.glCode, g.id ]) )), error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`) }); this.loadPurchases(); this.loadDueInterest(); } /** GL id for a GL code — the purchase API takes ids, the form works with codes. */ private glIdByCode = new Map(); /** The Holdings register and transaction log are the purchases saved in the backend. */ private loadPurchases(): void { this.investmentService.getPurchases().subscribe({ next: (purchases) => { this.holdings = purchases.map(toHolding); this.txns = purchases.map((d) => this.purchaseTxn(d)); this.purchaseForm = this.blankPurchaseForm(); }, error: (err) => this.toast(`Could not load purchases: ${apiErrorMessage(err)}`) }); } /** A purchase reaches the GL only when it is settled, dated the settlement date. */ private purchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn { const posted = !!d.glTransactionId; return { certNo: d.certNo, instrumentType: d.bondType, txnType: 'PURCHASE', voucherNo: `INV/PUR/${d.dealNo}`, txnDate: d.settlementDate, amount: d.settlementAmount, particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]`, glCode: posted ? d.investmentGlCode : '', glName: posted ? `${d.investmentGlName} (txn ${d.glTransactionId})` : 'Not posted (pending settlement)' }; } activeTab: BondTab = 'holdings'; /** Purchase Entry shows only the mandatory fields; the rest sit under "More Details". */ showMoreDetails = false; /** Cert No typed in the Retrieve box (Cert No itself is system-generated and read-only). */ retrieveCertNo = ''; bondType: BondType = 'GOVT_SECURITY'; asOnDate = todayDDMMYYYY(); /** Loaded from the backend (saved purchases) in ngOnInit. */ holdings: InvestmentHolding[] = []; txns: InvestmentTxn[] = []; // Master-driven dropdown sources readonly scriptOptions = SCRIPT_MASTER; readonly partyOptions = PARTY_MASTER; readonly subTypeOptions = SUBTYPE_MASTER; readonly brokerOptions = BROKER_MASTER; readonly sglBankOptions = SGL_BANK_MASTER; readonly mfProductOptions = MUTUAL_FUND_PRODUCTS; readonly lienReasonOptions = LIEN_REASON_MASTER; readonly categoryOptions = CATEGORY_OPTIONS; readonly dealTypeOptions = DEAL_TYPE_OPTIONS; readonly marketOptions = MARKET_OPTIONS; readonly paymentModeOptions = PAYMENT_MODE_OPTIONS; readonly deliveryModeOptions = DELIVERY_MODE_OPTIONS; readonly bondRatingOptions = BOND_RATING_OPTIONS; readonly intTypeOptions = INT_TYPE_OPTIONS; readonly authorityOptions = AUTHORITY_OPTIONS; generating = false; toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; purchaseForm: BondPurchaseForm = this.blankPurchaseForm(); sellForm: BondSellForm = this.blankSellForm(); readonly settlementBankOptions = SETTLEMENT_BANK_GL_OPTIONS; // Redemption Entry redeemCertNo = ''; redeemDate = this.asOnDate; redeemRemarks = ''; // 03 Interest Entry (legacy: Type / Script / Deal No + Retrieve) — the backend works out every // half-yearly coupon and 31 Mar / 30 Sep closing provision of the holding. Display only: // nothing is posted to GL until the posting rules are approved. intType: BondType | '' = ''; intScript = ''; intPurchaseId: number | null = null; intAsOnDate = this.asOnDate; intSchedule: InvestmentInterestScheduleDto | null = null; intLoading = false; /** Interest lines of all holdings due now or within two days — what Jitendra Sir asked to be reminded of. */ intDueTasks: InvestmentInterestDueDto[] = []; /** By default the schedule shows only lines up to the end of next year (and anything already posted). */ intShowFullSchedule = false; /** Bank a coupon is received into; defaults to the purchase's paying bank. */ intBankGlCode = ''; intPosting = false; readonly interestReceivableGl = INTEREST_RECEIVABLE_GL; // Write-off writeoffCertNo = ''; writeoffReason = ''; // Amortize amortizeCertNo = ''; amortizeAmt: number | null = null; // Amortize Yearly Process amortizeYear = String(new Date().getFullYear()); // GOI Category Transfer goiCertNo = ''; goiFromCategory = ''; goiToCategory = ''; goiTransferDate = this.asOnDate; // Investment Depreciation depreciationDate = this.asOnDate; depreciationCategory = ''; // Mutual Fund Transfer mfFromCertNo = ''; mfToProduct = ''; mfTransferUnits: number | null = null; mfTransferDate = this.asOnDate; readonly bondTypeOptions: Array<{ value: BondType; label: string }> = [ { value: 'GOVT_SECURITY', label: 'GOVT SECURITY' }, { value: 'NON_SLR', label: 'NON SLR' }, { value: 'BOND', label: 'BOND' }, { value: 'MUTUAL_FUND', label: 'MUTUAL FUND' } ]; setTab(tab: BondTab): void { this.activeTab = tab; } setBondType(type: BondType): void { this.bondType = type; this.purchaseForm.bondType = type; } get holdingsForType(): InvestmentHolding[] { return this.holdings.filter((h) => h.instrumentType === this.bondType); } get activeHoldingsForType(): InvestmentHolding[] { return this.holdingsForType.filter((h) => h.status === 'ACTIVE'); } get txnsForType(): InvestmentTxn[] { return this.txns.filter((t) => t.instrumentType === this.bondType); } get scriptsForType(): typeof SCRIPT_MASTER { return this.scriptOptions.filter((s) => s.instrumentType === this.purchaseForm.bondType); } instrumentLabel(type: InstrumentType): string { return INSTRUMENT_LABELS[type]; } /** Preview of the deal number the backend will assign: FY prefix (e.g. 202627) + running number. */ private nextDealNo(): string { const d = parseDDMMYYYY(this.asOnDate); const fyStart = d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1; const prefix = `${fyStart}${String((fyStart + 1) % 100).padStart(2, '0')}`; const nums = (this.holdings ?? []) .map((h) => h.dealNo) .filter((n): n is string => !!n && n.startsWith(prefix)) .map((n) => parseInt(n.slice(prefix.length), 10)); return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(6, '0')}`; } /** Certificate No is system-generated, as in the existing CBS (greyed there). */ /** Preview of the certificate number the backend will assign (running number). */ private nextCertNo(): string { const nums = (this.holdings ?? []).map((h) => parseInt(h.certNo, 10)).filter((n) => !isNaN(n)); return String((nums.length ? Math.max(...nums) : 0) + 1); } private blankPurchaseForm(): BondPurchaseForm { const type = this.bondType ?? 'GOVT_SECURITY'; this.showMoreDetails = false; this.retrieveCertNo = ''; this.purchaseMarketRate = undefined; return { bondType: type, dealNo: this.nextDealNo(), dealDate: this.asOnDate, dealTime: new Date().toTimeString().slice(0, 8), isin: '', scriptCode: '', certNo: this.nextCertNo(), settlementType: 'T+0', settlementDt: this.asOnDate, faceValue: null, purchaseRate: 100, partyCode: '', subType: type === 'GOVT_SECURITY' ? 'SLR' : type === 'NON_SLR' ? 'NON SLR' : 'CORPORATE BOND', tbillGlAc: '', crToAc: SETTLEMENT_BANK_GL_OPTIONS[0]?.glCode ?? '', premiumAmt: null, brokerage: null, brokerName: '', tradeNo: '', orderNo: '', recommAuthority: '', sanctionAuthority: '', issueDate: this.asOnDate, periodYear: 3, periodMonth: 0, periodDays: 0, descInv: '', folioNo: '', callOption: '', meetDt: '', resoNo: '', fromDist: '', toDist: '', redumMatDt: '', category: '', intRate: null, intType: 'HALF_YEARLY', intDate: this.asOnDate, lastIntDt: this.asOnDate, dealType: 'OUTRIGHT', market: 'NDS-OM', markCblo: 'NO', amortizeAmt: null, remarks: '', lien: 'NO', lienAmount: null, lienReason: '', deliveryMode: '', bondRating: type === 'GOVT_SECURITY' ? 'SOVEREIGN' : 'AAA', corpBond: type === 'BOND' ? 'YES' : 'NO', paymentMode: '', sglWithBank: '', description: '', registerAdd: '', regiCity: '', regiPin: '', productName: '', considerAmt: null, tradeAmt: null, nav: null, holdingDays: null, stampDuty: null, stampDutyAc: '', otherCharges: null }; } private blankSellForm(): BondSellForm { return { mode: 'SELL', searchBy: 'DEAL_NO', searchValue: '', bondType: this.bondType ?? 'GOVT_SECURITY', scriptCode: '', certNo: '', buyerCode: '', subType: '', isin: '', tbillGlAc: '', dealDate: this.asOnDate, dealTime: '', settlementType: 'T+0', settlementDt: this.asOnDate, faceValue: null, sellRate: null, tdsAmt: null, unitVal: 100, payAmt: null, sellUnit: null, brokerage: null, dbToAc: '310070', amortizeAmt: null, recommAuthority: '', sanctionAuthority: '', callMoney: false }; } get isMutualFund(): boolean { return this.purchaseForm.bondType === 'MUTUAL_FUND'; } /** Face value for the purchase — Mutual Fund screens label it "Consider Amt" instead. */ get purchaseBaseAmount(): number { const f = this.purchaseForm; return (this.isMutualFund ? f.considerAmt : f.faceValue) || 0; } get purchaseMaturityDate(): string { const f = this.purchaseForm; if (f.redumMatDt) { return f.redumMatDt; } if (!f.issueDate) { return ''; } return addPeriod(f.issueDate, f.periodYear || 0, f.periodMonth || 0, f.periodDays || 0); } get purchaseUnits(): number { const f = this.purchaseForm; const amt = this.purchaseBaseAmount; if (this.isMutualFund) { return f.nav ? +(amt / f.nav).toFixed(3) : 0; } return f.purchaseRate ? Math.round((amt / f.purchaseRate) * (f.purchaseRate / 100)) : 0; } /** Unit Val — the per-unit face value used by the legacy form (100 for G-Sec/Bonds, NAV for MF). */ get purchaseUnitVal(): number { return this.isMutualFund ? this.purchaseForm.nav || 0 : 100; } /** Book Value — the price paid for the security. Charges are expenses and stay out of it (Jitendra Sir). */ get purchaseBookValue(): number { const f = this.purchaseForm; if (this.isMutualFund) { return this.purchaseBaseAmount; } const rate = f.purchaseRate || 100; return Math.round((this.purchaseBaseAmount * rate) / 100); } /** Stamp duty + brokerage + other charges — paid with the settlement, booked to expense GLs. */ get purchaseTotalCharges(): number { const f = this.purchaseForm; return (f.stampDuty || 0) + (f.brokerage || 0) + (f.otherCharges || 0); } // Interest cycle of the security being bought: its two coupon days and the next coupon after the deal. get purchaseCouponDates(): string { return this.purchaseMaturityDate ? couponDatesLabel(this.purchaseMaturityDate) : ''; } get purchaseNextCouponDate(): string { const f = this.purchaseForm; return this.purchaseMaturityDate && f.dealDate ? nextHalfYearlyCouponDate(this.purchaseMaturityDate, f.dealDate) : ''; } get purchaseNextCouponAmount(): number { const f = this.purchaseForm; return f.intRate ? Math.round((this.purchaseBaseAmount * f.intRate) / 200) : 0; } get purchaseDaysToNextCoupon(): number { return this.purchaseNextCouponDate ? days360(this.purchaseForm.dealDate, this.purchaseNextCouponDate) : 0; } get purchaseYtm(): number { const f = this.purchaseForm; if (!f.intRate || !this.purchaseMaturityDate) { return 0; } return approxYtm(f.intRate, f.purchaseRate || 100, yearsBetween(f.issueDate, 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) { return 0; } return Math.round((this.purchaseBaseAmount * (f.intRate / 100) * this.purchaseAccruedDays) / 360); } get purchaseAccruedDays(): number { const f = this.purchaseForm; // Up to the settlement date, when the bond leaves the seller (RBI para 83; Jitendra Sir). return f.lastIntDt && this.purchaseSettlementDate ? days360(f.lastIntDt, this.purchaseSettlementDate) : 0; } /** Premium (+) or discount (−) paid over face value. */ get purchasePremiumDiscount(): number { const f = this.purchaseForm; if (this.isMutualFund || !f.faceValue || !f.purchaseRate) { return 0; } return Math.round((f.faceValue * (f.purchaseRate - 100)) / 100); } /** Re-suggests the last coupon date when the deal date changes. */ onDealDateChange(): void { this.suggestLastIntDate(); this.loadMarketRate(); } 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; } } /** 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; } get purchaseMaturityAmount(): number { const f = this.purchaseForm; if (!this.purchaseBaseAmount || !this.purchaseMaturityDate) { return this.purchaseBaseAmount; } return maturityAmount({ certNo: f.certNo, instrumentType: f.bondType, scriptCode: f.scriptCode, scriptName: '', party: f.partyCode, subType: f.subType, faceValue: this.purchaseBaseAmount, purchaseRate: f.purchaseRate || 100, units: this.purchaseUnits, purchaseDate: f.issueDate, maturityDate: this.purchaseMaturityDate, interestRate: f.intRate || 0, interestFreq: this.isMutualFund ? 'CUMULATIVE' : f.intType, glCode: '', glName: '', crToAc: f.crToAc, brokerCode: f.brokerName, status: 'ACTIVE' }); } get purchaseScript(): (typeof SCRIPT_MASTER)[number] | undefined { return this.scriptOptions.find((x) => x.scriptCode === this.purchaseForm.scriptCode); } /** Picking a script pulls its master details into the deal, as the existing CBS does. */ onScriptChange(): void { const s = this.purchaseScript; if (!s) { return; } const f = this.purchaseForm; f.isin = s.isin; if (s.couponRate != null) { f.intRate = s.couponRate; } if (s.issueDate) { f.issueDate = s.issueDate; } if (s.maturityDate) { f.redumMatDt = s.maturityDate; const p = periodBetween(s.issueDate, s.maturityDate); f.periodYear = p.years; f.periodMonth = p.months; f.periodDays = p.days; } if (s.slrType) { f.subType = s.slrType === 'NON_SLR' ? 'NON SLR' : 'SLR'; } if (this.isMutualFund) { f.productName = s.scriptName; } this.suggestLastIntDate(); this.loadMarketRate(); } /** * Market rate of the script for the deal date from the Daily Script Master (newest rate on or before it). It fills * the purchase rate (NAV for a mutual fund); the dealer may still change it. undefined = not looked up yet, * null = no rate entered for the script. */ purchaseMarketRate: InvestmentDailyRateDto | null | undefined; private loadMarketRate(): void { const s = this.purchaseScript; const f = this.purchaseForm; if (!s?.id || !f.dealDate) { this.purchaseMarketRate = undefined; return; } this.investmentService.getLatestDailyRate(s.id, f.dealDate).subscribe({ next: (rate) => { this.purchaseMarketRate = rate; // No market rate: clear the field rather than leave another script's rate in it. if (this.isMutualFund) { f.nav = rate ? rate.closeRate : null; } else { f.purchaseRate = rate ? rate.closeRate : null; } }, error: (err) => this.toast(`Could not read the market rate: ${apiErrorMessage(err)}`) }); } /** True when the rate entered is outside the day's low–high in the Daily Script Master. */ get purchaseRateOutOfRange(): boolean { const m = this.purchaseMarketRate; const f = this.purchaseForm; const rate = this.isMutualFund ? f.nav : f.purchaseRate; if (!m || rate == null) { return false; } return (m.lowRate != null && rate < m.lowRate) || (m.highRate != null && rate > m.highRate); } /** Settlement Date = Deal Date + T+n days (greyed / auto in the existing CBS). */ get purchaseSettlementDate(): string { const f = this.purchaseForm; if (!f.dealDate) { return ''; } const n = parseInt(f.settlementType.replace('T+', ''), 10) || 0; return addPeriod(f.dealDate, 0, 0, n); } /** Parties offered for a new deal — inactive parties are hidden. */ get activePartyOptions(): typeof PARTY_MASTER { return this.partyOptions.filter((p) => p.status !== 'INACTIVE'); } get purchaseInvestmentGlCode(): string { return this.purchaseScript?.glCode || INVESTMENT_GL_BY_TYPE[this.purchaseForm.bondType]; } glNameOf(glCode: string): string { return INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ?? ''; } /** The journal entry this purchase will post once the checker approves it. */ get purchaseAccountingPreview(): AccountingPreviewLine[] { const book = this.purchaseBookValue; const accrued = this.purchaseAccruedInterest; const total = this.purchaseSettlementAmount; const crGl = this.purchaseForm.crToAc; if (!book || !crGl) { return []; } const invGl = this.purchaseInvestmentGlCode; const lines: AccountingPreviewLine[] = [ { side: 'Dr', glCode: invGl, glName: this.glNameOf(invGl), amount: book }]; // Broken-period interest paid to the seller is an expense (RBI para 152, confirmed by Jitendra Sir). if (accrued > 0) { lines.push({ side: 'Dr', glCode: BROKEN_PERIOD_INTEREST_GL.glCode, glName: BROKEN_PERIOD_INTEREST_GL.glName, amount: accrued }); } const f = this.purchaseForm; for (const [ gl, amount ] of [ [ STAMP_DUTY_GL, f.stampDuty ], [ BROKERAGE_GL, f.brokerage ], [ OTHER_CHARGES_GL, f.otherCharges ] ] as const) { if (amount && amount > 0) { lines.push({ side: 'Dr', glCode: gl.glCode, glName: gl.glName, amount }); } } lines.push({ side: 'Cr', glCode: crGl, glName: this.glNameOf(crGl), amount: total }); return lines; } savePurchase(): void { const f = this.purchaseForm; const missing: string[] = []; if (!f.scriptCode) missing.push('Script Code'); if (!f.partyCode) missing.push('Party Code'); if (!f.dealDate) missing.push('Deal Date'); if (!f.dealTime) missing.push('Deal Time'); if (this.isMutualFund) { if (!f.considerAmt) missing.push('Consider Amt'); if (!f.nav) missing.push('NAV'); } else { if (!f.faceValue) missing.push('Face Value'); if (!f.purchaseRate) missing.push('Purchase Rate'); if (!f.lastIntDt) missing.push('Last Interest Date'); } if (!f.category) missing.push('Category'); if (!f.crToAc) missing.push('CR. To A/c'); if (missing.length) { this.toast(`Please fill: ${missing.join(', ')}.`); return; } const script = this.purchaseScript; const party = this.partyOptions.find((p) => p.partyCode === f.partyCode); const crGlAccountId = this.glIdByCode.get(f.crToAc); if (!script?.id || !party?.id) { this.toast('Script and Party must be saved in Investment Masters first.'); return; } if (!crGlAccountId) { this.toast(`GL ${f.crToAc} was not found in the chart of accounts.`); return; } const body = { bondType: f.bondType, scriptId: script.id, partyId: party.id, dealDate: f.dealDate, dealTime: f.dealTime || '', settlementType: f.settlementType, ...(this.isMutualFund ? { considerAmt: f.considerAmt, nav: f.nav } : { faceValue: f.faceValue, purchaseRate: f.purchaseRate, lastIntDate: f.lastIntDt }), stampDuty: f.stampDuty ?? null, brokerage: f.brokerage ?? null, otherCharges: f.otherCharges ?? null, category: f.category, crGlAccountId, subType: f.subType || '', otherDetails: this.purchaseOtherDetails(), dateFormat: 'dd-MM-yyyy', locale: 'en' }; this.investmentService.createPurchase(body).subscribe({ next: (res) => { this.toast( `Deal ${res.resourceIdentifier} saved — pending settlement. Settle it from Holdings on or after the settlement date.` ); this.activeTab = 'holdings'; this.loadPurchases(); }, error: (err) => this.toast(apiErrorMessage(err)) }); } /** The optional "More Details" fields, kept with the deal as they were entered. */ private purchaseOtherDetails(): Record { const f = this.purchaseForm; const fields: Record = { tradeNo: f.tradeNo, orderNo: f.orderNo, brokerCode: f.brokerName, recommAuthority: f.recommAuthority, sanctionAuthority: f.sanctionAuthority, meetDate: f.meetDt, resoNo: f.resoNo, dealType: f.dealType, market: f.market, markCblo: f.markCblo, paymentMode: f.paymentMode, deliveryMode: f.deliveryMode, lien: f.lien, lienAmount: f.lienAmount, lienReason: f.lienReason, bondRating: f.bondRating, corpBond: f.corpBond, sglWithBank: f.sglWithBank, tbillGlAc: f.tbillGlAc, intType: f.intType, intDate: f.intDate, folioNo: f.folioNo, callOption: f.callOption, descInv: f.descInv, fromDist: f.fromDist, toDist: f.toDist, amortizeAmt: f.amortizeAmt, registerAddress: f.registerAdd, registerCity: f.regiCity, registerPin: f.regiPin, remarks: f.remarks, description: f.description, tradeAmt: f.tradeAmt, holdingDays: f.holdingDays, stampDutyAc: f.stampDutyAc }; const out: Record = {}; for (const [ k, v ] of Object.entries(fields)) { if (v !== null && v !== undefined && v !== '') { out[k] = v; } } return out; } cancelPurchase(): void { this.purchaseForm = this.blankPurchaseForm(); this.toast('Entry cancelled — form reset.'); } /** Legacy "Retrieve" button — loads an existing deal back into the form by Cert No. */ retrievePurchase(): void { const certNo = this.retrieveCertNo.trim(); const h = this.holdings.find((x) => x.certNo === certNo); if (!h) { this.toast(`Cert No ${certNo || '(blank)'} not found.`); return; } this.purchaseForm = { ...this.blankPurchaseForm(), bondType: h.instrumentType as BondType, certNo: h.certNo, scriptCode: h.scriptCode, isin: this.scriptOptions.find((s) => s.scriptCode === h.scriptCode)?.isin || '', dealNo: h.dealNo ?? '', partyCode: h.partyCode ?? '', subType: h.subType, faceValue: h.faceValue, considerAmt: h.faceValue, purchaseRate: h.purchaseRate, nav: h.purchaseRate, issueDate: h.purchaseDate, redumMatDt: h.maturityDate, intRate: h.interestRate, intType: h.interestFreq, brokerName: h.brokerCode, crToAc: h.crToAc, productName: h.scriptName, category: h.category ?? '' }; this.bondType = h.instrumentType as BondType; this.toast(`Deal for Cert No ${h.certNo} retrieved.`); } /** Posts a pending deal to GL dated its settlement date; under maker-checker it waits in the Checker Inbox. */ settleDeal(h: InvestmentHolding): void { if (!h.id) { return; } this.investmentService.settlePurchase(h.id).subscribe({ next: (res) => { this.toast( res.rollbackTransaction ? `Settlement of deal ${h.dealNo} sent for approval (Checker Inbox).` : `Deal ${h.dealNo} settled and posted to GL (transaction ${res.transactionId}).` ); this.loadPurchases(); }, error: (err) => this.toast(apiErrorMessage(err)) }); } dropPurchase(): void { const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo); if (!h?.id) { this.toast('Retrieve a saved deal first, then Drop.'); return; } this.investmentService.deletePurchase(h.id).subscribe({ next: () => { this.toast(`Deal ${h.dealNo} (Cert No ${h.certNo}) dropped.`); this.loadPurchases(); }, error: (err) => this.toast(apiErrorMessage(err)) }); } exitPurchase(): void { this.purchaseForm = this.blankPurchaseForm(); this.activeTab = 'holdings'; } // -------------------- Redemption Entry -------------------- get redeemHolding(): InvestmentHolding | undefined { return this.holdingsForType.find((h) => h.certNo === this.redeemCertNo); } get redeemAmount(): number { const h = this.redeemHolding; return h ? maturityAmount(h) + accruedInterest(h, this.redeemDate) - h.faceValue + h.faceValue : 0; } saveRedemption(): void { const h = this.redeemHolding; if (!h) { this.toast('Select a certificate to redeem.'); return; } h.status = 'MATURED'; this.txns = [ { certNo: h.certNo, instrumentType: h.instrumentType, txnType: 'REDEEM', voucherNo: `INV/RED/${h.certNo}`, txnDate: this.redeemDate, amount: this.redeemAmount, particulars: `Redemption on maturity - ${h.scriptName}${this.redeemRemarks ? ' - ' + this.redeemRemarks : ''}` }, ...this.txns ]; this.toast(`${h.certNo} redeemed. Proceeds ₹${this.redeemAmount.toLocaleString('en-IN')}.`); this.redeemCertNo = ''; this.redeemRemarks = ''; this.activeTab = 'holdings'; } // -------------------- Interest calculation -------------------- /** Saved holdings for the Deal No dropdown, narrowed by Type and Script. */ get intDealOptions(): InvestmentHolding[] { return this.holdings.filter( (h) => h.id != null && h.status === 'ACTIVE' && (!this.intType || h.instrumentType === this.intType) && (!this.intScript || h.scriptCode === this.intScript) ); } get intScriptOptions(): typeof SCRIPT_MASTER { return this.scriptOptions.filter((s) => !this.intType || s.instrumentType === this.intType); } onIntFilterChange(): void { if (this.intScript && !this.intScriptOptions.some((s) => s.scriptCode === this.intScript)) { this.intScript = ''; } if (this.intPurchaseId != null && !this.intDealOptions.some((h) => h.id === this.intPurchaseId)) { this.intPurchaseId = null; } this.intSchedule = null; } retrieveInterestSchedule(): void { if (this.intPurchaseId == null) { this.toast('Select a Deal No first.'); return; } this.intLoading = true; this.investmentService.getInterestSchedule(this.intPurchaseId, this.intAsOnDate || undefined).subscribe({ next: (schedule) => { this.intSchedule = schedule; this.intBankGlCode = schedule.bankGlCode; this.intLoading = false; }, error: (err) => { this.intSchedule = null; this.intLoading = false; this.toast(apiErrorMessage(err)); } }); } loadDueInterest(): void { this.investmentService.getDueInterest().subscribe({ next: (tasks) => (this.intDueTasks = tasks), error: () => (this.intDueTasks = []) }); } /** Opens a due task's holding in the schedule below. */ openDueTask(t: InvestmentInterestDueDto): void { this.intType = ''; this.intScript = ''; this.intPurchaseId = t.purchaseId; this.retrieveInterestSchedule(); } /** Lines shown: up to 31 December of next year, plus anything already posted — unless the full schedule is asked for. */ get intVisibleRows(): InvestmentInterestRowDto[] { const rows = this.intSchedule?.rows ?? []; if (this.intShowFullSchedule) { return rows; } const upTo = new Date(new Date().getFullYear() + 1, 11, 31); return rows.filter((r) => r.posted || parseDDMMYYYY(r.intDate) <= upTo); } /** Posts one interest line with its JV voucher (passed as it is raised), then refreshes the schedule and tasks. */ generateVoucher(r: InvestmentInterestRowDto): void { const sc = this.intSchedule; if (!sc || this.intPosting) { return; } const bankGlAccountId = r.type === 'COUPON' ? (this.glIdByCode.get(this.intBankGlCode) ?? sc.bankGlAccountId) : undefined; const body = { lineType: r.type, intDate: r.intDate, ...(bankGlAccountId ? { bankGlAccountId } : {}), dateFormat: 'dd-MM-yyyy', locale: 'en' }; this.intPosting = true; this.investmentService.postInterest(sc.purchaseId, body).subscribe({ next: (res) => { this.intPosting = false; this.toast( `${r.type === 'COUPON' ? 'Coupon' : 'Provision'} of ${r.intDate} posted — voucher ${res.resourceIdentifier ?? ''}.` ); this.retrieveInterestSchedule(); this.loadDueInterest(); }, error: (err) => { this.intPosting = false; this.toast(apiErrorMessage(err)); } }); } /** 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); } // -------------------- Sell / Redeem Entry -------------------- /** Locates the original purchase by Deal No / Cert No or ISIN and fills the sell form. */ searchPurchaseForSell(): void { const val = this.sellForm.searchValue.trim(); if (!val) { this.toast('Enter a Deal No / Cert No or ISIN to search.'); return; } const h = this.sellForm.searchBy === 'DEAL_NO' ? this.holdings.find((x) => x.certNo === val) : this.holdings.find((x) => this.scriptOptions.find((s) => s.scriptCode === x.scriptCode)?.isin === val); if (!h) { this.toast('No matching purchase found.'); return; } this.sellForm = { ...this.sellForm, bondType: h.instrumentType as BondType, scriptCode: h.scriptCode, certNo: h.certNo, subType: h.subType, isin: this.scriptOptions.find((s) => s.scriptCode === h.scriptCode)?.isin || '', faceValue: h.faceValue, unitVal: h.instrumentType === 'MUTUAL_FUND' ? h.purchaseRate : 100, sellUnit: h.units }; this.toast(`Purchase ${h.certNo} loaded.`); } get sellHolding(): InvestmentHolding | undefined { return this.holdings.find((h) => h.certNo === this.sellForm.certNo); } /** Consideration = units sold × sell rate, scaled by the instrument's unit convention. */ get sellConsideration(): number { const f = this.sellForm; if (!f.sellUnit || !f.sellRate) { return 0; } const perUnitDivisor = f.mode === 'MUTUAL_FUND_SELL' ? 1 : 100; return Math.round((f.sellUnit * f.sellRate) / perUnitDivisor); } /** Sale Diff — profit/loss versus what the holding was bought at. */ get sellSaleDiff(): number { const h = this.sellHolding; const f = this.sellForm; if (!h || !f.sellUnit || !f.sellRate) { return 0; } const perUnitDivisor = f.mode === 'MUTUAL_FUND_SELL' ? 1 : 100; const cost = Math.round((f.sellUnit * h.purchaseRate) / perUnitDivisor); return this.sellConsideration - cost; } /** Premium Loss — the unamortised premium written off when a holding is sold early. */ get sellPremiumLoss(): number { const h = this.sellHolding; if (!h || h.purchaseRate <= 100 || h.instrumentType === 'MUTUAL_FUND') { return 0; } return Math.round((h.faceValue * (h.purchaseRate - 100)) / 100); } get sellAccruedInterest(): number { const h = this.sellHolding; return h ? accruedInterest(h, this.sellForm.dealDate || this.asOnDate) : 0; } get sellAccruedDays(): number { const h = this.sellHolding; if (!h) { return 0; } return Math.max(0, Math.round(yearsBetween(h.purchaseDate, this.sellForm.dealDate || this.asOnDate) * 365)); } /** Pay Amt — net amount receivable: consideration + accrued interest − TDS − brokerage. */ get sellPayAmount(): number { const f = this.sellForm; return this.sellConsideration + this.sellAccruedInterest - (f.tdsAmt || 0) - (f.brokerage || 0); } saveSell(): void { const f = this.sellForm; const h = this.sellHolding; if (!h || !f.sellRate || !f.sellUnit) { this.toast('Load a purchase, then enter Sell Rate and Sell Unit.'); return; } if (f.sellUnit > h.units) { this.toast(`Sell Unit cannot exceed units held (${h.units}).`); return; } h.units -= f.sellUnit; h.faceValue = Math.round(h.units * (f.unitVal || 100)); if (h.units <= 0) { h.status = f.mode === 'REDEEM' ? 'MATURED' : 'SOLD'; } this.txns = [ { certNo: h.certNo, instrumentType: h.instrumentType, txnType: f.mode === 'REDEEM' ? 'REDEEM' : 'SELL', voucherNo: `INV/${f.mode === 'REDEEM' ? 'RED' : 'SEL'}/${h.certNo}-${this.txns.length + 1}`, txnDate: f.dealDate, amount: this.sellPayAmount, particulars: `${f.mode.replace(/_/g, ' ')} - ${h.scriptName} @ ${f.sellRate}${f.buyerCode ? ' to ' + f.buyerCode : ''}${f.callMoney ? ' [CALL MONEY]' : ''}` }, ...this.txns ]; this.toast( `${h.certNo} ${f.mode === 'REDEEM' ? 'redeemed' : 'sold'}. Pay Amt ₹${this.sellPayAmount.toLocaleString('en-IN')}.` ); this.sellForm = this.blankSellForm(); this.activeTab = 'holdings'; } cancelSell(): void { this.sellForm = this.blankSellForm(); this.toast('Sell entry cancelled — form reset.'); } exitSell(): void { this.sellForm = this.blankSellForm(); this.activeTab = 'holdings'; } // -------------------- Write-off -------------------- get writeoffHolding(): InvestmentHolding | undefined { return this.holdingsForType.find((h) => h.certNo === this.writeoffCertNo); } saveWriteoff(): void { const h = this.writeoffHolding; if (!h || !this.writeoffReason) { this.toast('Select a certificate and enter a write-off reason.'); return; } h.status = 'WRITTEN_OFF'; this.txns = [ { certNo: h.certNo, instrumentType: h.instrumentType, txnType: 'WRITE_OFF', voucherNo: `INV/WOF/${h.certNo}`, txnDate: this.asOnDate, amount: h.faceValue, particulars: `Written off - ${this.writeoffReason}` }, ...this.txns ]; this.toast(`${h.certNo} written off.`); this.writeoffCertNo = ''; this.writeoffReason = ''; this.activeTab = 'holdings'; } // -------------------- Amortize -------------------- get amortizeHolding(): InvestmentHolding | undefined { return this.holdingsForType.find((h) => h.certNo === this.amortizeCertNo); } /** Straight-line premium amortisation for one year on the selected holding. */ get suggestedAmortizeAmt(): number { const h = this.amortizeHolding; if (!h || h.purchaseRate <= 100) { return 0; } const years = Math.max(1, yearsBetween(h.purchaseDate, h.maturityDate)); return Math.round((h.faceValue * (h.purchaseRate - 100)) / 100 / years); } saveAmortize(): void { const h = this.amortizeHolding; const amt = this.amortizeAmt ?? this.suggestedAmortizeAmt; if (!h || !amt) { this.toast('Select a certificate and enter the amortization amount.'); return; } this.txns = [ { certNo: h.certNo, instrumentType: h.instrumentType, txnType: 'AMORTIZE', voucherNo: `INV/AMZ/${h.certNo}-${this.txns.length + 1}`, txnDate: this.asOnDate, amount: amt, particulars: `Premium/discount amortized - ${h.scriptName}` }, ...this.txns ]; this.toast(`Amortization of ₹${amt.toLocaleString('en-IN')} booked for ${h.certNo}.`); this.amortizeCertNo = ''; this.amortizeAmt = null; } // -------------------- Amortize Yearly Process -------------------- /** All premium-carrying holdings the yearly batch would amortise. */ get amortizeYearlyTargets(): Array<{ holding: InvestmentHolding; amount: number }> { return this.holdings .filter((h) => h.status === 'ACTIVE' && h.purchaseRate > 100 && h.instrumentType !== 'MUTUAL_FUND') .map((h) => { const years = Math.max(1, yearsBetween(h.purchaseDate, h.maturityDate)); return { holding: h, amount: Math.round((h.faceValue * (h.purchaseRate - 100)) / 100 / years) }; }); } get amortizeYearlyTotal(): number { return this.amortizeYearlyTargets.reduce((s, t) => s + t.amount, 0); } runAmortizeYearly(): void { const targets = this.amortizeYearlyTargets; if (targets.length === 0) { this.toast('No premium-carrying holdings to amortise.'); return; } const newTxns: InvestmentTxn[] = targets.map((t, i) => ({ certNo: t.holding.certNo, instrumentType: t.holding.instrumentType, txnType: 'AMORTIZE', voucherNo: `INV/AMZ-YR/${this.amortizeYear}-${i + 1}`, txnDate: this.asOnDate, amount: t.amount, particulars: `Yearly amortization process ${this.amortizeYear} - ${t.holding.scriptName}` })); this.txns = [ ...newTxns, ...this.txns ]; this.toast( `Yearly amortization run for ${targets.length} holding(s). Total ₹${this.amortizeYearlyTotal.toLocaleString('en-IN')}.` ); } // -------------------- GOI Category Transfer -------------------- get goiHolding(): InvestmentHolding | undefined { return this.holdings.find((h) => h.certNo === this.goiCertNo); } saveGoiTransfer(): void { const h = this.goiHolding; if (!h || !this.goiFromCategory || !this.goiToCategory) { this.toast('Select a certificate and both From / To categories.'); return; } if (this.goiFromCategory === this.goiToCategory) { this.toast('From and To categories must differ.'); return; } this.txns = [ { certNo: h.certNo, instrumentType: h.instrumentType, txnType: 'AMORTIZE', voucherNo: `INV/GOI-TRF/${h.certNo}-${this.txns.length + 1}`, txnDate: this.goiTransferDate, amount: h.faceValue, particulars: `GOI category transfer ${this.goiFromCategory} → ${this.goiToCategory} - ${h.scriptName}` }, ...this.txns ]; this.toast(`${h.certNo} transferred from ${this.goiFromCategory} to ${this.goiToCategory}.`); this.goiCertNo = ''; this.goiFromCategory = ''; this.goiToCategory = ''; } // -------------------- Investment Depreciation -------------------- /** AFS/HFT holdings whose market price is below book value must be marked down (RBI norms). */ get depreciationTargets(): Array<{ holding: InvestmentHolding; marketPrice: number; depreciation: number }> { return this.holdings .filter((h) => h.status === 'ACTIVE' && h.instrumentType !== 'MUTUAL_FUND') .map((h) => { const md = this.scriptOptions.find((s) => s.scriptCode === h.scriptCode); const marketPrice = md ? h.purchaseRate * 0.995 : h.purchaseRate; const depreciation = marketPrice < h.purchaseRate ? Math.round((h.faceValue * (h.purchaseRate - marketPrice)) / 100) : 0; return { holding: h, marketPrice: +marketPrice.toFixed(2), depreciation }; }) .filter((r) => r.depreciation > 0); } get depreciationTotal(): number { return this.depreciationTargets.reduce((s, r) => s + r.depreciation, 0); } runDepreciation(): void { const targets = this.depreciationTargets; if (targets.length === 0) { this.toast('No holdings require depreciation provisioning.'); return; } const newTxns: InvestmentTxn[] = targets.map((t, i) => ({ certNo: t.holding.certNo, instrumentType: t.holding.instrumentType, txnType: 'AMORTIZE', voucherNo: `INV/DEPR/${this.depreciationDate.replace(/-/g, '')}-${i + 1}`, txnDate: this.depreciationDate, amount: t.depreciation, particulars: `Investment depreciation provision - ${t.holding.scriptName} (market ${t.marketPrice})` })); this.txns = [ ...newTxns, ...this.txns ]; this.toast( `Depreciation provisioned for ${targets.length} holding(s). Total ₹${this.depreciationTotal.toLocaleString('en-IN')}.` ); } // -------------------- Mutual Fund Transfer -------------------- get mfHoldings(): InvestmentHolding[] { return this.holdings.filter((h) => h.instrumentType === 'MUTUAL_FUND' && h.status === 'ACTIVE'); } get mfFromHolding(): InvestmentHolding | undefined { return this.holdings.find((h) => h.certNo === this.mfFromCertNo); } get mfTransferValue(): number { const h = this.mfFromHolding; if (!h || !this.mfTransferUnits) { return 0; } return Math.round(this.mfTransferUnits * h.purchaseRate); } saveMfTransfer(): void { const h = this.mfFromHolding; if (!h || !this.mfToProduct || !this.mfTransferUnits) { this.toast('Select source holding, target product and units to transfer.'); return; } if (this.mfTransferUnits > h.units) { this.toast(`Units cannot exceed units held (${h.units}).`); return; } const value = this.mfTransferValue; h.units = +(h.units - this.mfTransferUnits).toFixed(3); h.faceValue = Math.round(h.units * h.purchaseRate); if (h.units <= 0) { h.status = 'SOLD'; } const product = this.mfProductOptions.find((p) => p.productName === this.mfToProduct); const newCert = `MF-${5000 + this.holdings.filter((x) => x.instrumentType === 'MUTUAL_FUND').length + 1}`; this.holdings = [ { certNo: newCert, instrumentType: 'MUTUAL_FUND', scriptCode: product?.productCode || this.mfToProduct, scriptName: this.mfToProduct, party: product?.amcName || '', subType: product?.category || 'DEBT', faceValue: value, purchaseRate: h.purchaseRate, units: this.mfTransferUnits, purchaseDate: this.mfTransferDate, maturityDate: addPeriod(this.mfTransferDate, 1, 0), interestRate: h.interestRate, interestFreq: 'CUMULATIVE', glCode: '310050', glName: 'INVESTMENT IN MUTUAL FUNDS', crToAc: h.crToAc, brokerCode: '', status: 'ACTIVE' }, ...this.holdings ]; this.txns = [ { certNo: newCert, instrumentType: 'MUTUAL_FUND', txnType: 'PURCHASE', voucherNo: `INV/MF-TRF/${newCert}`, txnDate: this.mfTransferDate, amount: value, particulars: `Mutual Fund switch from ${h.scriptName} to ${this.mfToProduct} (${this.mfTransferUnits} units)` }, ...this.txns ]; this.toast(`Transferred ${this.mfTransferUnits} units to ${this.mfToProduct} as ${newCert}.`); this.mfFromCertNo = ''; this.mfToProduct = ''; this.mfTransferUnits = null; } // -------------------- Helpers -------------------- accrued(h: InvestmentHolding): number { return accruedInterest(h, this.asOnDate); } maturity(h: InvestmentHolding): number { return maturityAmount(h); } daysLeft(h: InvestmentHolding): number { return daysToMaturity(h, this.asOnDate); } statusClass(h: InvestmentHolding): string { return `inv-badge inv-badge--${h.status.toLowerCase()}`; } // -------------------- Export to Excel (Holdings register) -------------------- async exportExcel(): Promise { this.generating = true; try { await exportWorkbook( [ { sheetName: this.instrumentLabel(this.bondType), title: `${this.instrumentLabel(this.bondType)} Holdings Register`, subtitle: `As on ${this.asOnDate}`, headers: [ 'Cert No', 'Script', 'Party', 'Units', 'Face Value', 'Rate', 'Purchase Date', 'Maturity Date', 'Accrued Interest', 'Maturity Value', 'Status' ], widths: [ 14, 26, 22, 12, 16, 10, 14, 14, 16, 16, 12 ], rows: this.holdingsForType.map((h) => [ h.certNo, h.scriptName, h.party, h.units, h.faceValue, h.interestRate, h.purchaseDate, h.maturityDate, this.accrued(h), this.maturity(h), h.status ]) } ], `${this.instrumentLabel(this.bondType)}-Holdings-${this.asOnDate}` ); this.toast(`${this.instrumentLabel(this.bondType)} Holdings exported.`); } catch (err) { console.error('Bond Holdings export failed', err); this.toast('Could not generate the workbook — please retry.'); } finally { this.generating = false; } } private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; if (this.toastTimer) { clearTimeout(this.toastTimer); } this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); } }