/** * 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 { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component'; import { FD_HOLDINGS, ALL_TXNS, PARTY_MASTER, SUBTYPE_MASTER, FD_TYPE_OPTIONS, INT_TYPE_OPTIONS, LIEN_REASON_MASTER, INTEREST_INCOME_GL_MASTER, INTEREST_INCOME_GL_BY_TYPE, InvestmentHolding, InvestmentTxn, InterestFreq, accruedInterest, accruedInterestBetween, canAccrueInterest, daysToMaturity, maturityAmount, addPeriod, todayDDMMYYYY } from '../investment-data'; import { exportWorkbook } from '../investment-export'; type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment'; interface FdPurchaseForm { tranType: 'FRESH' | 'RENEWAL'; oldCertNo: string; fdType: string; /** GENERAL uses the period (Y/M/D) fields; DATEWISE lets the user type the maturity date directly. */ matureAmtCalcType: 'GENERAL' | 'DATEWISE'; certNo: string; certDate: string; party: string; subParty: string; glAcNo: string; glAcName: string; subType: string; receiptAdvice: 'RECEIPT' | 'ADVICE'; transactionDate: string; depositDate: string; depositAmount: number | null; interestFreq: InterestFreq; interestDate: string; interestRate: number | null; periodYear: number | null; periodMonth: number | null; periodDays: number | null; /** Only used when matureAmtCalcType === 'DATEWISE'. */ manualMaturityDate: string; resolutionNo: string; crBranchCode: string; crToAc: string; crToAcName: string; intReceivableCode: string; lien: 'YES' | 'NO'; lienAmt: number | null; lienReason: string; classification: string; collable: 'YES' | 'NO'; } /** * FD (Fixed Deposit) treasury investment screen — Holdings register + Purchase / Interest / * Sell-Premature / Write-off / Part-Payment entry tabs, modelled on the legacy "Investment - FD * Purchase Entry" form. Dummy in-memory data only; Save appends to the local holdings/txns * arrays so the demo feels interactive without any backend. */ @Component({ selector: 'mifosx-fd-investment', templateUrl: './fd-investment.component.html', styleUrls: [ '../../cashier/cashier-shared.scss', '../investment-shared.scss' ], standalone: true, imports: [ CommonModule, FormsModule, DateDdmmyyyyPickerComponent ] }) export class FdInvestmentComponent { activeTab: FdTab = 'holdings'; asOnDate = todayDDMMYYYY(); holdings: InvestmentHolding[] = [...FD_HOLDINGS]; txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'FD'); readonly partyOptions = PARTY_MASTER; readonly subTypeOptions = SUBTYPE_MASTER; readonly fdTypeOptions = FD_TYPE_OPTIONS; readonly intTypeOptions = INT_TYPE_OPTIONS; readonly lienReasonOptions = LIEN_REASON_MASTER; readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER; readonly branchOptions = [ 'HEAD OFFICE', 'BRANCH 1', 'BRANCH 2' ]; generating = false; toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; purchaseForm: FdPurchaseForm = this.blankPurchaseForm(); /** Certificate No typed into the Purchase Entry's Search box (legacy "Search" button). */ searchCertNo = ''; interestCertNo = ''; interestFromDate = addPeriod(this.asOnDate, 0, -3); interestToDate = this.asOnDate; interestGlCode = INTEREST_INCOME_GL_BY_TYPE.FD; sellCertNo = ''; sellDate = this.asOnDate; sellRemark = ''; writeoffCertNo = ''; writeoffReason = ''; partPayCertNo = ''; partPayAmount: number | null = null; setTab(tab: FdTab): void { this.activeTab = tab; } get activeHoldings(): InvestmentHolding[] { return this.holdings.filter((h) => h.status === 'ACTIVE'); } private nextCertNo(): string { const nums = this.holdings.map((h) => parseInt(h.certNo, 10)).filter((n) => !isNaN(n)); return String((nums.length ? Math.max(...nums) : 15619) + 1); } private blankPurchaseForm(): FdPurchaseForm { return { tranType: 'FRESH', oldCertNo: '', fdType: 'CUMMULATIVE', matureAmtCalcType: 'GENERAL', certNo: this.nextCertNo(), certDate: this.asOnDate, party: '', subParty: '', glAcNo: '320020', glAcName: 'FIXED DEPO. CO-OPER. BANK MAIN', subType: 'NON SLR', receiptAdvice: 'RECEIPT', transactionDate: this.asOnDate, depositDate: this.asOnDate, depositAmount: null, interestFreq: 'CUMULATIVE', interestDate: this.asOnDate, interestRate: null, periodYear: 2, periodMonth: 0, periodDays: 0, manualMaturityDate: '', resolutionNo: '', crBranchCode: 'HEAD OFFICE', crToAc: '310070', crToAcName: 'CURRENT A/C CO-OP. BANK MAIN BR.41', intReceivableCode: '320020', lien: 'NO', lienAmt: null, lienReason: '', classification: '', collable: 'NO' }; } get purchaseMaturityDate(): string { const f = this.purchaseForm; if (f.matureAmtCalcType === 'DATEWISE') { return f.manualMaturityDate; } if (!f.depositDate) { return ''; } const base = addPeriod(f.depositDate, f.periodYear || 0, f.periodMonth || 0); return f.periodDays ? addPeriod(base, 0, 0, f.periodDays) : base; } get purchaseMaturityAmount(): number { const f = this.purchaseForm; if (!f.depositAmount || !f.interestRate || !this.purchaseMaturityDate) { return 0; } return maturityAmount({ certNo: f.certNo, instrumentType: 'FD', scriptCode: '', scriptName: '', party: f.party, subType: f.subType, faceValue: f.depositAmount, purchaseRate: 100, units: 1, purchaseDate: f.depositDate, maturityDate: this.purchaseMaturityDate, interestRate: f.interestRate, interestFreq: f.interestFreq, glCode: f.glAcNo, glName: '', crToAc: f.crToAc, brokerCode: '', status: 'ACTIVE' }); } savePurchase(): void { const f = this.purchaseForm; if (!f.party || !f.depositAmount || !f.interestRate || !this.purchaseMaturityDate) { this.toast('Please fill Party, Deposit Amount, Interest Rate and Period.'); return; } const holding: InvestmentHolding = { certNo: f.certNo, instrumentType: 'FD', scriptCode: f.interestFreq === 'CUMULATIVE' ? 'FD-CUM' : 'FD-MIP', scriptName: f.fdType ? `${f.fdType} Fixed Deposit` : 'Fixed Deposit', party: f.party, subType: f.subType, faceValue: f.depositAmount, purchaseRate: 100, units: 1, purchaseDate: f.depositDate, maturityDate: this.purchaseMaturityDate, interestRate: f.interestRate, interestFreq: f.interestFreq, glCode: f.glAcNo, glName: f.glAcName, crToAc: f.crToAc, brokerCode: '', status: 'ACTIVE' }; this.holdings = [ holding, ...this.holdings ]; this.txns = [ { certNo: holding.certNo, instrumentType: 'FD', txnType: 'PURCHASE', voucherNo: `INV/PUR/FD-${holding.certNo}`, txnDate: f.transactionDate, amount: holding.faceValue, particulars: `${f.tranType === 'RENEWAL' ? 'Renewal' : 'Fresh'} FD Purchase - ${holding.party}` }, ...this.txns ]; this.toast(`FD Certificate No. ${holding.certNo} saved successfully.`); this.purchaseForm = this.blankPurchaseForm(); this.activeTab = 'holdings'; } cancelPurchase(): void { this.purchaseForm = this.blankPurchaseForm(); this.toast('Entry cancelled — form reset.'); } /** Legacy "Search" button — loads an existing certificate's values back into the form. */ searchPurchase(): void { const h = this.holdings.find((x) => x.certNo === this.searchCertNo.trim()); if (!h) { this.toast(`Certificate No. ${this.searchCertNo} not found.`); return; } this.purchaseForm = { ...this.blankPurchaseForm(), tranType: 'FRESH', certNo: h.certNo, certDate: h.purchaseDate, party: h.party, subType: h.subType, glAcNo: h.glCode, glAcName: h.glName, transactionDate: h.purchaseDate, depositDate: h.purchaseDate, depositAmount: h.faceValue, interestFreq: h.interestFreq, interestDate: h.purchaseDate, interestRate: h.interestRate, matureAmtCalcType: 'DATEWISE', manualMaturityDate: h.maturityDate, crToAc: h.crToAc }; this.toast(`Certificate ${h.certNo} loaded.`); } /** Legacy "Drop" button — removes the loaded certificate from the register. */ dropPurchase(): void { const certNo = this.purchaseForm.certNo; const exists = this.holdings.some((h) => h.certNo === certNo); if (!exists) { this.toast('Nothing to drop — this certificate is not yet saved.'); return; } this.holdings = this.holdings.filter((h) => h.certNo !== certNo); this.txns = this.txns.filter((t) => t.certNo !== certNo); this.purchaseForm = this.blankPurchaseForm(); this.toast(`Certificate ${certNo} dropped.`); } /** Legacy "Exit" button — closes the entry form and returns to the register. */ exitPurchase(): void { this.purchaseForm = this.blankPurchaseForm(); this.activeTab = 'holdings'; } // -------------------- Interest -------------------- /** Certificates eligible for accrual-basis posting — same rule as the Investment Interest * module: a Non-Performing holding never appears here, only through Interest Receive. */ get interestEligibleHoldings(): InvestmentHolding[] { return this.activeHoldings.filter((h) => canAccrueInterest(h)); } get interestHolding(): InvestmentHolding | undefined { return this.holdings.find((h) => h.certNo === this.interestCertNo); } /** Interest accrued strictly within [interestFromDate, interestToDate] — a period-based * Interest Calculation & Post run, same basis as the Investment Interest module. */ interestAccrued(h: InvestmentHolding): number { return accruedInterestBetween(h, this.interestFromDate || h.purchaseDate, this.interestToDate || this.asOnDate); } glName(glCode: string): string { return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode; } postInterest(): void { const h = this.interestHolding; if (!h) { this.toast('Select a certificate first.'); return; } const amt = this.interestAccrued(h); if (amt <= 0) { this.toast('No interest due to be posted for this period.'); return; } const toDate = this.interestToDate || this.asOnDate; const fromDate = this.interestFromDate || toDate; const glCode = this.interestGlCode; const glName = this.glName(glCode); h.interestPosted = (h.interestPosted ?? 0) + amt; h.interestPostedTillDate = toDate; this.interestFromDate = toDate; this.txns = [ { certNo: h.certNo, instrumentType: 'FD', txnType: 'INTEREST', voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`, txnDate: toDate, amount: amt, particulars: `Interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party} (${h.interestFreq})`, glCode, glName }, ...this.txns ]; this.toast( `Interest of ₹${amt.toLocaleString('en-IN')} posted to ${glCode} - ${glName} for Certificate ${h.certNo}.` ); this.interestCertNo = ''; } // -------------------- Sell / Premature -------------------- get sellHolding(): InvestmentHolding | undefined { return this.holdings.find((h) => h.certNo === this.sellCertNo); } saveSell(): void { const h = this.sellHolding; if (!h || !this.sellDate) { this.toast('Select a certificate and sell/premature date.'); return; } const payout = h.faceValue + accruedInterest(h, this.sellDate); h.status = 'SOLD'; this.txns = [ { certNo: h.certNo, instrumentType: 'FD', txnType: 'SELL', voucherNo: `INV/SEL/FD-${h.certNo}`, txnDate: this.sellDate, amount: payout, particulars: `Premature closure - ${h.party}${this.sellRemark ? ' - ' + this.sellRemark : ''}` }, ...this.txns ]; this.toast(`Certificate ${h.certNo} closed. Payout ₹${payout.toLocaleString('en-IN')}.`); this.sellCertNo = ''; this.sellRemark = ''; this.activeTab = 'holdings'; } // -------------------- Write-off -------------------- get writeoffHolding(): InvestmentHolding | undefined { return this.holdings.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: 'FD', txnType: 'WRITE_OFF', voucherNo: `INV/WOF/FD-${h.certNo}`, txnDate: this.asOnDate, amount: h.faceValue, particulars: `Written off - ${this.writeoffReason}` }, ...this.txns ]; this.toast(`Certificate ${h.certNo} written off.`); this.writeoffCertNo = ''; this.writeoffReason = ''; this.activeTab = 'holdings'; } // -------------------- Part Payment -------------------- get partPayHolding(): InvestmentHolding | undefined { return this.holdings.find((h) => h.certNo === this.partPayCertNo); } savePartPayment(): void { const h = this.partPayHolding; if (!h || !this.partPayAmount || this.partPayAmount <= 0 || this.partPayAmount >= h.faceValue) { this.toast('Select a certificate and enter a valid part-payment amount (less than face value).'); return; } h.faceValue -= this.partPayAmount; this.txns = [ { certNo: h.certNo, instrumentType: 'FD', txnType: 'PART_PAYMENT', voucherNo: `INV/PP/FD-${h.certNo}-${this.txns.length + 1}`, txnDate: this.asOnDate, amount: this.partPayAmount, particulars: `Part payment withdrawn - ${h.party}, balance ₹${h.faceValue.toLocaleString('en-IN')}` }, ...this.txns ]; this.toast(`Part payment of ₹${this.partPayAmount.toLocaleString('en-IN')} recorded for Certificate ${h.certNo}.`); this.partPayCertNo = ''; this.partPayAmount = null; this.activeTab = 'holdings'; } // -------------------- 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: 'FD Holdings', title: 'FD Holdings Register', subtitle: `As on ${this.asOnDate}`, headers: [ 'Cert No', 'Party', 'Type', 'Face Value', 'Rate', 'Frequency', 'Purchase Date', 'Maturity Date', 'Accrued Interest', 'Maturity Value', 'Status' ], widths: [ 14, 28, 18, 16, 10, 14, 14, 14, 16, 16, 12 ], rows: this.holdings.map((h) => [ h.certNo, h.party, h.interestFreq === 'CUMULATIVE' ? 'Cumulative' : 'Payout', h.faceValue, h.interestRate, h.interestFreq, h.purchaseDate, h.maturityDate, this.accrued(h), this.maturity(h), h.status ]) } ], `FD-Holdings-${this.asOnDate}` ); this.toast('FD Holdings exported.'); } catch (err) { console.error('FD 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); } }