feat(investment): add Treasury Investment module (FD, Govt Sec/Non-SLR/Bond/MF, Share)

Adds full frontend-only Investment module mirroring the legacy AutoBank CBS
Investment menu: Dashboard, FD/Bond/Share entry screens (purchase, interest,
sell/redeem, write-off, amortize and more), 10 master screens, an Interest
calculation/register screen, and ~21 reports. All figures are derived from a
single holdings/transactions dataset so entry, interest, and reports can
never drift out of sync. Wires the new "Investment" mega-menu into the shell
and routes the previously inert Investment stub buttons.
This commit is contained in:
2026-08-01 09:17:06 +05:30
parent fd28788d23
commit 63ac900e42
22 changed files with 8249 additions and 6 deletions
@@ -0,0 +1,247 @@
/**
* 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,
INSTRUMENT_LABELS,
accruedInterest,
daysToMaturity,
maturityAmount,
todayDDMMYYYY
} from '../investment-data';
type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' | 'fdRegister';
/**
* The legacy "Interest" top-menu for Investment — 5 screens:
* 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation (both batch "Process" runs),
* 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register (listings).
*
* 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.
*/
@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 = 'fdCalc';
asOnDate = todayDDMMYYYY();
fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS];
bondHoldings: InvestmentHolding[] = [
...GOVT_SECURITY_HOLDINGS,
...NON_SLR_HOLDINGS,
...BOND_HOLDINGS
];
txns: InvestmentTxn[] = [...ALL_TXNS];
readonly scriptOptions = SCRIPT_MASTER;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | 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);
}
// -------------------- 01 FD Interest Calculation --------------------
get fdCalcTargets(): InvestmentHolding[] {
if (this.fdCalcAll) {
return this.fdHoldings.filter((h) => h.status === 'ACTIVE');
}
return this.fdHoldings.filter(
(h) => h.status === 'ACTIVE' && (!this.fdCalcCertNo || h.certNo === this.fdCalcCertNo)
);
}
fdInterest(h: InvestmentHolding): number {
return accruedInterest(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;
if (targets.length === 0) {
this.toast('No FD holdings match the selected filters.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
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: this.fdInterest(h),
particulars: `FD interest calculated - ${h.party}`
}));
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`FD interest processed for ${targets.length} certificate(s). Total ₹${this.fdCalcTotal.toLocaleString('en-IN')}.`
);
}
// -------------------- 02 Govt./Bond Int. Calculation --------------------
get bondCalcTargets(): InvestmentHolding[] {
if (this.bondCalcAll) {
return this.bondHoldings.filter((h) => h.status === 'ACTIVE');
}
return this.bondHoldings.filter((h) => {
if (h.status !== 'ACTIVE') return false;
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;
});
}
bondInterest(h: InvestmentHolding): number {
return accruedInterest(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;
if (targets.length === 0) {
this.toast('No Govt/Bond holdings match the selected filters.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
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: this.bondInterest(h),
particulars: `Coupon/interest calculated - ${h.scriptName}`
}));
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`Interest processed for ${targets.length} holding(s). Total ₹${this.bondCalcTotal.toLocaleString('en-IN')}.`
);
}
// -------------------- 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);
}
}