/** * 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 { ActivatedRoute } from '@angular/router'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component'; import { addPeriod, parseDDMMYYYY, todayDDMMYYYY } from '../investment-data'; import { FdCalcType, FdClosureType, FdPayout, InvestmentFdClosureDto, InvestmentFdInterestRowDto, InvestmentFdScheduleDto, InvestmentFdDto, InvestmentGlAccount, InvestmentPartyDto, InvestmentService, apiErrorMessage, awaitingApproval, sentForApproval } from '../investment.service'; type FdTab = 'holdings' | 'purchase' | 'closure' | 'interest'; interface FdClosureForm { fdId: number | null; type: FdClosureType; closeDate: string; /** Maturity / premature: money credited by the bank. Renewal: the maturity amount as per the bank. */ receivedAmount: number | null; tds: number | null; penalAmount: number | null; principalBankGlAccountId: number | null; interestBankGlAccountId: number | null; newCertNo: string; newCertDate: string; newIntRate: number | null; newPeriodYears: number | null; newPeriodMonths: number | null; newPeriodDays: number | null; newMaturityDate: string; newPayout: FdPayout; newCalcType: FdCalcType; renewalAmount: number | null; diffBankGlAccountId: number | null; remarks: string; } interface FdPurchaseForm { partyId: number | null; certNo: string; certDate: string; dealDate: string; amount: number | null; intRate: number | null; periodYears: number | null; periodMonths: number | null; periodDays: number | null; maturityDate: string; payout: FdPayout; calcType: FdCalcType; bankGlAccountId: number | null; remarks: string; } /** * FDs the bank places with other banks (Jitendra Sir): the bank is an FD party with its own FD GL and interest GL; * placing an FD posts Dr the bank's FD GL / Cr our bank GL at once. The RBI limits (20% of total deposits as on the * previous 31 March with all banks, 5% with one bank) are checked before saving. */ @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 implements OnInit { private readonly investmentService = inject(InvestmentService); private readonly route = inject(ActivatedRoute); activeTab: FdTab = 'holdings'; fds: InvestmentFdDto[] = []; fdParties: InvestmentPartyDto[] = []; /** Our bank accounts (current accounts under 100200) the money can be paid from. */ bankGlOptions: InvestmentGlAccount[] = []; readonly payoutOptions: Array<{ value: FdPayout; label: string }> = [ { value: 'MATURITY', label: 'At Maturity' }, { value: 'HALF_YEARLY', label: 'Half-Yearly' }, { value: 'YEARLY', label: 'Yearly' } ]; readonly calcTypeOptions: Array<{ value: FdCalcType; label: string }> = [ { value: 'QUARTERLY', label: 'Compound – Quarterly' }, { value: 'MONTHLY', label: 'Compound – Monthly' }, { value: 'HALF_YEARLY', label: 'Compound – Half-Yearly' }, { value: 'YEARLY', label: 'Compound – Yearly' }, { value: 'SIMPLE', label: 'Simple Interest' } ]; form: FdPurchaseForm = this.blankForm(); quote: InvestmentFdDto | null = null; quoteError = ''; saving = false; // 03 Interest Entry interestFdId: number | null = null; schedule: InvestmentFdScheduleDto | null = null; payoutReceived: number | null = null; payoutTds: number | null = null; payoutBankGlAccountId: number | null = null; postingInterest = false; closure: FdClosureForm = this.blankClosure(); closureQuote: InvestmentFdClosureDto | null = null; closureError = ''; closing = false; /** Total deposits as on the 31 March the limits use, when it still has to be entered. */ baseAmount: number | null = null; savingBase = false; toastMsg = ''; 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)), error: (err) => this.toast(`Could not load FD banks: ${apiErrorMessage(err)}`) }); this.investmentService.getAssetGlAccounts().subscribe({ next: (gls) => (this.bankGlOptions = gls.filter((g) => g.glCode.startsWith('1002'))), error: (err) => this.toast(`Could not load bank GLs: ${apiErrorMessage(err)}`) }); this.loadFds(); // Opened from a reminder (home screen / day close): straight to the FD's interest lines. const fdId = Number(this.route.snapshot.queryParamMap.get('fd')); if (this.route.snapshot.queryParamMap.get('tab') === 'interest') { this.activeTab = 'interest'; if (fdId) { this.interestFdId = fdId; this.loadSchedule(); } } } setTab(tab: FdTab): void { this.activeTab = tab; if (tab === 'purchase') { this.refreshQuote(); } else if (tab === 'closure' && this.closure.fdId) { // Interest may have been posted on the other tab meanwhile: work the closure out again. this.refreshClosureQuote(); } else if (tab === 'interest' && this.interestFdId) { this.loadSchedule(); } } /** A complete DD-MM-YYYY date: half-typed dates are not sent to the server. */ private static fullDate(d: string | null | undefined): boolean { return !!d && /^\d{2}-\d{2}-\d{4}$/.test(d); } private loadFds(): void { this.investmentService.getFds().subscribe({ next: (list) => (this.fds = list), error: (err) => this.toast(`Could not load FDs: ${apiErrorMessage(err)}`) }); } get selectedParty(): InvestmentPartyDto | undefined { return this.fdParties.find((p) => p.id === this.form.partyId); } get activeFds(): InvestmentFdDto[] { return this.fds.filter((f) => f.status === 'ACTIVE'); } get totalPlaced(): number { return this.activeFds.reduce((sum, f) => sum + (f.principal ?? 0), 0); } payoutLabel(p?: string): string { return this.payoutOptions.find((o) => o.value === p)?.label ?? p ?? ''; } calcLabel(c?: string): string { return this.calcTypeOptions.find((o) => o.value === c)?.label ?? c ?? ''; } // -------------------- Purchase -------------------- /** Period (years / months / days) sets the maturity date; the maturity date can also be typed directly. */ onPeriodChange(): void { const f = this.form; if (FdInvestmentComponent.fullDate(f.dealDate) && (f.periodYears || f.periodMonths || f.periodDays)) { f.maturityDate = addPeriod(f.dealDate, f.periodYears ?? 0, f.periodMonths ?? 0, f.periodDays ?? 0); } this.refreshQuote(); } /** Asks the backend for the interest, maturity amount and the RBI limit position of the FD being entered. */ refreshQuote(): void { const f = this.form; this.quoteError = ''; if (f.dealDate && !FdInvestmentComponent.fullDate(f.dealDate)) { return; } const params: Record = { dealDate: f.dealDate || todayDDMMYYYY(), payout: f.payout, calcType: f.calcType }; if (f.partyId) { params['partyId'] = f.partyId; } if (f.amount) { params['amount'] = f.amount; } if (f.intRate) { params['intRate'] = f.intRate; } if (FdInvestmentComponent.fullDate(f.maturityDate)) { params['maturityDate'] = f.maturityDate; } this.investmentService.quoteFd(params).subscribe({ next: (q) => { this.quote = q; if (q.limit && q.limit.totalDeposits == null && this.baseAmount == null) { this.baseAmount = q.limit.glTotalDeposits || null; } }, error: (err) => { this.quote = null; this.quoteError = apiErrorMessage(err); } }); } saveBase(): void { const asOn = this.quote?.limit?.asOnDate; if (!asOn || !this.baseAmount || this.baseAmount <= 0) { this.toast('Enter the total deposits as on 31 March.'); return; } this.savingBase = true; this.investmentService .setDepositBase({ asOnDate: asOn, totalDeposits: this.baseAmount, locale: 'en', dateFormat: 'dd-MM-yyyy' }) .subscribe({ next: (res) => { this.savingBase = false; this.toast( awaitingApproval(res) ? sentForApproval(`Total deposits as on ${asOn}`) : `Total deposits as on ${asOn} saved.` ); this.refreshQuote(); }, error: (err) => { this.savingBase = false; this.toast(apiErrorMessage(err)); } }); } savePurchase(): void { const f = this.form; if ( !f.partyId || !f.certNo.trim() || !f.dealDate || !f.amount || !f.intRate || !f.maturityDate || !f.bankGlAccountId ) { this.toast( 'Enter the bank, certificate no, deal date, amount, rate, maturity date and the bank account paid from.' ); return; } if (this.quote?.limit && !this.quote.limit.withinLimits) { this.toast(this.quote.limit.message); return; } this.saving = true; this.investmentService .createFd({ partyId: f.partyId, certNo: f.certNo.trim(), ...(f.certDate ? { certDate: f.certDate } : {}), dealDate: f.dealDate, amount: f.amount, intRate: f.intRate, maturityDate: f.maturityDate, payout: f.payout, calcType: f.calcType, bankGlAccountId: f.bankGlAccountId, ...(f.remarks.trim() ? { remarks: f.remarks.trim() } : {}), locale: 'en', dateFormat: 'dd-MM-yyyy' }) .subscribe({ next: (res) => { this.saving = false; this.toast( awaitingApproval(res) ? sentForApproval(`FD of ₹${f.amount} with certificate ${f.certNo.trim()}`) : `FD ${res.changes?.dealNo ?? ''} placed — voucher ${res.changes?.voucherNo ?? res.resourceIdentifier ?? ''}.` ); this.form = this.blankForm(); this.quote = null; this.loadFds(); this.setTab('holdings'); }, error: (err) => { this.saving = false; this.toast(apiErrorMessage(err)); } }); } resetPurchase(): void { this.quoteError = ''; this.form = this.blankForm(); this.refreshQuote(); } // -------------------- Interest Entry -------------------- openInterest(f: InvestmentFdDto): void { this.interestFdId = f.id ?? null; this.setTab('interest'); this.loadSchedule(); } loadSchedule(): void { this.schedule = null; this.payoutReceived = null; this.payoutTds = null; if (!this.interestFdId) { return; } this.investmentService.getFdSchedule(this.interestFdId).subscribe({ next: (s) => { this.schedule = s; this.payoutBankGlAccountId = s.fd.bankGlAccountId ?? null; }, error: (err) => this.toast(apiErrorMessage(err)) }); } lineLabel(t: string): string { return t === 'PROVISION' ? 'Provision' : t === 'PAYOUT' ? 'Interest Payout' : 'Maturity'; } /** Posts the next line: a provision as worked out, a payout with the bank's amount and TDS if entered. */ postInterest(r: InvestmentFdInterestRowDto): void { const fdId = this.interestFdId; if (!fdId) { return; } const body: Record = { lineType: r.lineType, intDate: r.intDate, locale: 'en', dateFormat: 'dd-MM-yyyy' }; if (r.lineType === 'PAYOUT') { if (this.payoutReceived) { body['receivedAmount'] = this.payoutReceived; } // Blank TDS: the system works it out at the bank's rate; an entered figure (even 0) is used as it is. if (entered(this.payoutTds)) { body['tds'] = this.payoutTds; } if (this.payoutBankGlAccountId) { body['bankGlAccountId'] = this.payoutBankGlAccountId; } } this.postingInterest = true; this.investmentService.postFdInterest(fdId, body).subscribe({ next: (res) => { this.postingInterest = false; this.toast( awaitingApproval(res) ? sentForApproval(`${this.lineLabel(r.lineType)} of ${r.intDate} voucher`) : `${this.lineLabel(r.lineType)} of ${r.intDate} posted — voucher ${res.resourceIdentifier ?? ''}.` ); this.loadSchedule(); }, error: (err) => { this.postingInterest = false; this.toast(apiErrorMessage(err)); } }); } // -------------------- Maturity / Premature / Renewal -------------------- get closureFd(): InvestmentFdDto | undefined { return this.fds.find((f) => f.id === this.closure.fdId); } /** Maturity and renewal happen on or after the maturity date; before it only a premature closure is possible. */ get closureFdMatured(): boolean { const f = this.closureFd; return !!f?.maturityDate && parseDDMMYYYY(f.maturityDate) <= parseDDMMYYYY(todayDDMMYYYY()); } /** The renewal top-up (positive) or amount taken out (negative), as worked out by the backend. */ get closureTopUp(): number { return this.closureQuote?.topUp ?? 0; } openClosure(f: InvestmentFdDto): void { this.closure = this.blankClosure(); this.closure.fdId = f.id ?? null; this.onClosureFdChange(); this.setTab('closure'); } onClosureFdChange(): void { const f = this.closureFd; const c = this.closure; c.receivedAmount = null; c.renewalAmount = null; c.type = this.closureFdMatured ? 'MATURITY' : 'PREMATURE'; c.principalBankGlAccountId = f?.bankGlAccountId ?? null; c.interestBankGlAccountId = f?.bankGlAccountId ?? null; c.newPayout = f?.payout ?? 'MATURITY'; c.newCalcType = f?.calcType ?? 'QUARTERLY'; this.refreshClosureQuote(); } onNewPeriodChange(): void { const c = this.closure; const start = this.closureFd?.maturityDate; if (start && (c.newPeriodYears || c.newPeriodMonths || c.newPeriodDays)) { c.newMaturityDate = addPeriod(start, c.newPeriodYears ?? 0, c.newPeriodMonths ?? 0, c.newPeriodDays ?? 0); } } refreshClosureQuote(): void { const c = this.closure; this.closureError = ''; if (!c.fdId) { this.closureQuote = null; return; } if (c.type === 'PREMATURE' && c.closeDate && !FdInvestmentComponent.fullDate(c.closeDate)) { return; } const params: Record = { type: c.type }; if (c.type === 'PREMATURE' && c.closeDate) { params['closeDate'] = c.closeDate; } if (c.type === 'RENEWAL') { if (c.diffBankGlAccountId) { params['diffBankGlAccountId'] = c.diffBankGlAccountId; } } else { if (c.principalBankGlAccountId) { params['principalBankGlAccountId'] = c.principalBankGlAccountId; } if (c.interestBankGlAccountId) { params['interestBankGlAccountId'] = c.interestBankGlAccountId; } } if (c.receivedAmount) { params['receivedAmount'] = c.receivedAmount; } if (entered(c.tds)) { params['tds'] = c.tds; } if (c.type === 'RENEWAL' && c.renewalAmount) { params['renewalAmount'] = c.renewalAmount; } this.investmentService.closureQuoteFd(c.fdId, params).subscribe({ next: (q) => (this.closureQuote = q), error: (err) => { this.closureQuote = null; this.closureError = apiErrorMessage(err); } }); } saveClosure(): void { const c = this.closure; const f = this.closureFd; if (!f?.id) { this.toast('Select the FD.'); return; } const base = { locale: 'en', dateFormat: 'dd-MM-yyyy', ...(c.remarks.trim() ? { remarks: c.remarks.trim() } : {}) }; const tds = entered(c.tds) ? { tds: c.tds } : {}; let body: object; if (c.type === 'RENEWAL') { if (!c.newCertNo.trim() || !c.newIntRate || !c.newMaturityDate) { this.toast('Enter the new certificate no, interest rate and maturity date.'); return; } const topUp = this.closureTopUp; if (topUp !== 0 && !c.diffBankGlAccountId) { this.toast( topUp > 0 ? 'Choose the bank the extra amount is paid from.' : 'Choose the bank the amount taken out goes to.' ); return; } body = { ...base, ...tds, newCertNo: c.newCertNo.trim(), ...(c.newCertDate ? { newCertDate: c.newCertDate } : {}), newIntRate: c.newIntRate, newMaturityDate: c.newMaturityDate, newPayout: c.newPayout, newCalcType: c.newCalcType, ...(c.receivedAmount ? { maturityAmount: c.receivedAmount } : {}), ...(c.renewalAmount ? { renewalAmount: c.renewalAmount } : {}), ...(topUp !== 0 && c.diffBankGlAccountId ? { diffBankGlAccountId: c.diffBankGlAccountId } : {}) }; } else { if (c.type === 'PREMATURE' && (!c.closeDate || !c.receivedAmount)) { this.toast('Enter the closure date and the amount received from the bank.'); return; } body = { ...base, ...tds, ...(c.type === 'PREMATURE' ? { closeDate: c.closeDate } : {}), ...(c.receivedAmount ? { receivedAmount: c.receivedAmount } : {}), ...(c.type === 'PREMATURE' && c.penalAmount ? { penalAmount: c.penalAmount } : {}), ...(c.principalBankGlAccountId ? { principalBankGlAccountId: c.principalBankGlAccountId } : {}), ...(c.interestBankGlAccountId ? { interestBankGlAccountId: c.interestBankGlAccountId } : {}) }; } this.closing = true; this.investmentService.closeFd(f.id, c.type, body).subscribe({ next: (res) => { this.closing = false; const what = c.type === 'RENEWAL' ? `renewed as ${res.changes?.dealNo ?? ''}` : c.type === 'PREMATURE' ? 'closed' : 'matured'; const action = c.type === 'RENEWAL' ? 'Renewal' : c.type === 'PREMATURE' ? 'Premature closure' : 'Maturity'; this.toast( awaitingApproval(res) ? sentForApproval(`${action} of FD ${f.dealNo}`) : `FD ${f.dealNo} ${what} — voucher ${res.changes?.voucherNo ?? res.resourceIdentifier ?? ''}.` ); this.closure = this.blankClosure(); this.closureQuote = null; this.loadFds(); this.setTab('holdings'); }, error: (err) => { this.closing = false; this.toast(apiErrorMessage(err)); } }); } private blankClosure(): FdClosureForm { return { fdId: null, type: 'MATURITY', closeDate: todayDDMMYYYY(), receivedAmount: null, tds: null, penalAmount: null, principalBankGlAccountId: null, interestBankGlAccountId: null, newCertNo: '', newCertDate: '', newIntRate: null, newPeriodYears: null, newPeriodMonths: null, newPeriodDays: null, newMaturityDate: '', newPayout: 'MATURITY', newCalcType: 'QUARTERLY', renewalAmount: null, diffBankGlAccountId: null, remarks: '' }; } bankGlName(id: number | null): string { const g = this.bankGlOptions.find((x) => x.id === id); return g ? `${g.glCode} ${g.name}` : ''; } private blankForm(): FdPurchaseForm { return { partyId: null, certNo: '', certDate: '', dealDate: todayDDMMYYYY(), amount: null, intRate: null, periodYears: null, periodMonths: null, periodDays: null, maturityDate: '', payout: 'MATURITY', calcType: 'QUARTERLY', bankGlAccountId: null, remarks: '' }; } private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; if (this.toastTimer) { clearTimeout(this.toastTimer); } this.toastTimer = setTimeout(() => (this.toastVisible = false), 4000); } } /** A number box the user filled in (0 counts; blank does not). */ function entered(v: number | null | undefined | string): boolean { return v !== null && v !== undefined && v !== ''; }