/** * 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 } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { FD_HOLDINGS, GOVT_SECURITY_HOLDINGS, NON_SLR_HOLDINGS, BOND_HOLDINGS, ALL_TXNS, SCRIPT_MASTER, InvestmentHolding, InvestmentTxn, InstrumentType, InvestmentProvisionStatus, INSTRUMENT_LABELS, PROVISION_STATUS_LABELS, accruedInterest, daysToMaturity, maturityAmount, todayDDMMYYYY, isNpi, canAccrueInterest, interestToPost, interestReceivableBalance, suspenseInterest, provisionRequiredInv } from '../investment-data'; type InterestTab = | 'receivable' | 'fdCalc' | 'bondCalc' | 'receive' | 'bondIntRegister' | 'fdIntRegister' | 'fdRegister'; /** * The legacy "Interest" top-menu for Investment — plus the full accrual lifecycle: Interest * Receivable, 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation, Interest Receive, * 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register. * * Interest figures are derived from the same holdings used by the Entry screens and Reports, * so a register here always agrees with what a Purchase/Interest entry recorded. * * Provisioning: a holding classified Non-Performing (Sub-Standard/Doubtful/Loss) can never have * interest calculated/posted on accrual basis (canAccrueInterest() blocks it) — only actual cash * receipts are recognised for it, straight through Interest Receive, matching RBI's income * recognition norms for Non-Performing Investments. */ @Component({ selector: 'mifosx-investment-interest', templateUrl: './investment-interest.component.html', styleUrls: [ '../../cashier/cashier-shared.scss', '../investment-shared.scss' ], standalone: true, imports: [ CommonModule, FormsModule ] }) export class InvestmentInterestComponent { activeTab: InterestTab = 'receivable'; asOnDate = todayDDMMYYYY(); fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS]; bondHoldings: InvestmentHolding[] = [ ...GOVT_SECURITY_HOLDINGS, ...NON_SLR_HOLDINGS, ...BOND_HOLDINGS ]; txns: InvestmentTxn[] = [...ALL_TXNS]; readonly scriptOptions = SCRIPT_MASTER; /** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */ receiveDrafts: Record = {}; toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; // 01 FD Interest Calculation fdCalcDate = this.asOnDate; fdCalcCertNo = ''; fdCalcAll = false; // 02 Govt./Bond Int. Calculation bondCalcDate = this.asOnDate; bondCalcType: InstrumentType | '' = ''; bondCalcScript = ''; bondCalcDealNo = ''; bondCalcAll = false; // Register filters registerFromDate = ''; registerToDate = ''; setTab(tab: InterestTab): void { this.activeTab = tab; } instrumentLabel(type: InstrumentType): string { return INSTRUMENT_LABELS[type]; } statusClass(h: InvestmentHolding): string { return `inv-badge inv-badge--${h.status.toLowerCase()}`; } maturity(h: InvestmentHolding): number { return maturityAmount(h); } daysLeft(h: InvestmentHolding): number { return daysToMaturity(h, this.asOnDate); } get allInterestHoldings(): InvestmentHolding[] { return [ ...this.fdHoldings, ...this.bondHoldings ].filter((h) => h.status === 'ACTIVE'); } isNpiHolding(h: InvestmentHolding): boolean { return isNpi(h); } provisionLabel(h: InvestmentHolding): string { return PROVISION_STATUS_LABELS[h.provisionStatus ?? 'STANDARD']; } provisionBadgeClass(h: InvestmentHolding): string { const status: InvestmentProvisionStatus = h.provisionStatus ?? 'STANDARD'; return `inv-badge inv-badge--${status.toLowerCase()}`; } posted(h: InvestmentHolding): number { return h.interestPosted ?? 0; } received(h: InvestmentHolding): number { return h.interestReceived ?? 0; } receivableBalance(h: InvestmentHolding): number { return interestReceivableBalance(h); } suspense(h: InvestmentHolding): number { return suspenseInterest(h, this.asOnDate); } provisionRequired(h: InvestmentHolding): number { return provisionRequiredInv(h); } // -------------------- 01 FD Interest Calculation -------------------- get fdCalcTargets(): InvestmentHolding[] { const base = this.fdHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h)); if (this.fdCalcAll) { return base; } return base.filter((h) => !this.fdCalcCertNo || h.certNo === this.fdCalcCertNo); } /** NPI FD certificates excluded from calculation/posting — interest recognised on cash basis * only, via the Interest Receive tab. */ get fdCalcExcludedNpi(): InvestmentHolding[] { return this.fdHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h)); } fdInterest(h: InvestmentHolding): number { return interestToPost(h, this.fdCalcDate || this.asOnDate); } get fdCalcTotal(): number { return this.fdCalcTargets.reduce((s, h) => s + this.fdInterest(h), 0); } processFdInterest(): void { const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0); if (targets.length === 0) { this.toast('No FD holdings have interest due to be posted.'); return; } const newTxns: InvestmentTxn[] = targets.map((h, i) => { const amt = this.fdInterest(h); h.interestPosted = this.posted(h) + amt; return { certNo: h.certNo, instrumentType: 'FD', txnType: 'INTEREST', voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, txnDate: this.fdCalcDate || this.asOnDate, amount: amt, particulars: `FD interest posted (accrual) - ${h.party}` }; }); const total = newTxns.reduce((s, t) => s + t.amount, 0); this.txns = [ ...newTxns, ...this.txns ]; this.toast(`FD interest posted for ${targets.length} certificate(s). Total ₹${total.toLocaleString('en-IN')}.`); } // -------------------- 02 Govt./Bond Int. Calculation -------------------- get bondCalcTargets(): InvestmentHolding[] { const base = this.bondHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h)); if (this.bondCalcAll) { return base; } return base.filter((h) => { if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false; if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false; if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false; return true; }); } /** NPI Govt/Bond holdings excluded from calculation/posting — interest recognised on cash * basis only, via the Interest Receive tab. */ get bondCalcExcludedNpi(): InvestmentHolding[] { return this.bondHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h)); } bondInterest(h: InvestmentHolding): number { return interestToPost(h, this.bondCalcDate || this.asOnDate); } get bondCalcTotal(): number { return this.bondCalcTargets.reduce((s, h) => s + this.bondInterest(h), 0); } processBondInterest(): void { const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0); if (targets.length === 0) { this.toast('No Govt/Bond holdings have interest due to be posted.'); return; } const newTxns: InvestmentTxn[] = targets.map((h, i) => { const amt = this.bondInterest(h); h.interestPosted = this.posted(h) + amt; return { certNo: h.certNo, instrumentType: h.instrumentType, txnType: 'INTEREST', voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, txnDate: this.bondCalcDate || this.asOnDate, amount: amt, particulars: `Coupon/interest posted (accrual) - ${h.scriptName}` }; }); const total = newTxns.reduce((s, t) => s + t.amount, 0); this.txns = [ ...newTxns, ...this.txns ]; this.toast(`Interest posted for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`); } // -------------------- Interest Receivable (all interest-bearing types) -------------------- /** Standard holdings with a posted-but-uncollected balance — a genuine "Interest Receivable" * GL asset. */ get receivableRows(): InvestmentHolding[] { return this.allInterestHoldings.filter((h) => canAccrueInterest(h) && this.receivableBalance(h) > 0); } /** NPI holdings with interest still accruing in memo/suspense — not a GL asset, shown * separately so the treasury desk can see what cash-basis income is outstanding. */ get suspenseRows(): InvestmentHolding[] { return this.allInterestHoldings.filter((h) => isNpi(h) && this.suspense(h) > 0); } get totalReceivable(): number { return this.receivableRows.reduce((s, h) => s + this.receivableBalance(h), 0); } get totalSuspense(): number { return this.suspenseRows.reduce((s, h) => s + this.suspense(h), 0); } // -------------------- Interest Receive (cash receipt, all interest-bearing types) -------------------- /** Everything with money outstanding to collect — Standard holdings against their posted * receivable, and NPI holdings against their notional suspense (cash-basis recognition). */ get receiveTargets(): InvestmentHolding[] { return this.allInterestHoldings.filter((h) => canAccrueInterest(h) ? this.receivableBalance(h) > 0 : this.suspense(h) > 0 ); } outstandingFor(h: InvestmentHolding): number { return canAccrueInterest(h) ? this.receivableBalance(h) : this.suspense(h); } get receivedLog(): InvestmentTxn[] { return this.txns.filter((t) => t.txnType === 'INTEREST_RECEIVED'); } get receivedLogTotal(): number { return this.receivedLog.reduce((s, t) => s + t.amount, 0); } receiveInterest(h: InvestmentHolding): void { const draft = this.receiveDrafts[h.certNo]; if (!draft || draft <= 0) { this.toast('Enter a valid amount to receive.'); return; } let amt = draft; let particulars: string; if (canAccrueInterest(h)) { amt = Math.min(draft, this.receivableBalance(h)); h.interestReceived = this.received(h) + amt; particulars = `Interest received against posted receivable - ${h.scriptName || h.party}`; } else { h.interestReceived = this.received(h) + amt; particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`; } this.txns = [ { certNo: h.certNo, instrumentType: h.instrumentType, txnType: 'INTEREST_RECEIVED', voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`, txnDate: this.asOnDate, amount: amt, particulars }, ...this.txns ]; this.toast(`₹${amt.toLocaleString('en-IN')} interest received for ${h.certNo}.`); delete this.receiveDrafts[h.certNo]; } // -------------------- 03 Government Bond Int Register -------------------- get bondIntRegister(): InvestmentTxn[] { return this.txns.filter( (t) => t.txnType === 'INTEREST' && ([ 'GOVT_SECURITY', 'NON_SLR', 'BOND' ] as InstrumentType[]).includes(t.instrumentType) ); } get bondIntRegisterTotal(): number { return this.bondIntRegister.reduce((s, t) => s + t.amount, 0); } // -------------------- 04 FD Interest Register -------------------- get fdIntRegister(): InvestmentTxn[] { return this.txns.filter((t) => t.txnType === 'INTEREST' && t.instrumentType === 'FD'); } get fdIntRegisterTotal(): number { return this.fdIntRegister.reduce((s, t) => s + t.amount, 0); } // -------------------- 05 FD Register -------------------- get fdRegister(): InvestmentHolding[] { return this.fdHoldings; } get fdRegisterFaceTotal(): number { return this.fdRegister.reduce((s, h) => s + h.faceValue, 0); } get fdRegisterMaturityTotal(): number { return this.fdRegister.reduce((s, h) => s + this.maturity(h), 0); } accrued(h: InvestmentHolding): number { return accruedInterest(h, this.asOnDate); } get fdRegisterAccruedTotal(): number { return this.fdRegister.reduce((s, h) => s + this.accrued(h), 0); } private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; if (this.toastTimer) { clearTimeout(this.toastTimer); } this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); } }