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:
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* 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,
|
||||
ALL_TXNS,
|
||||
PARTY_MASTER,
|
||||
SUBTYPE_MASTER,
|
||||
FD_TYPE_OPTIONS,
|
||||
INT_TYPE_OPTIONS,
|
||||
LIEN_REASON_MASTER,
|
||||
InvestmentHolding,
|
||||
InvestmentTxn,
|
||||
InterestFreq,
|
||||
accruedInterest,
|
||||
daysToMaturity,
|
||||
maturityAmount,
|
||||
addPeriod,
|
||||
todayDDMMYYYY
|
||||
} from '../investment-data';
|
||||
|
||||
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
|
||||
]
|
||||
})
|
||||
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 branchOptions = [
|
||||
'HEAD OFFICE',
|
||||
'BRANCH 1',
|
||||
'BRANCH 2'
|
||||
];
|
||||
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
purchaseForm: FdPurchaseForm = this.blankPurchaseForm();
|
||||
/** Certificate No typed into the Purchase Entry's Search box (legacy "Search" button). */
|
||||
searchCertNo = '';
|
||||
|
||||
interestCertNo = '';
|
||||
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 --------------------
|
||||
get interestHolding(): InvestmentHolding | undefined {
|
||||
return this.holdings.find((h) => h.certNo === this.interestCertNo);
|
||||
}
|
||||
|
||||
interestAccrued(h: InvestmentHolding): number {
|
||||
return accruedInterest(h, this.asOnDate);
|
||||
}
|
||||
|
||||
postInterest(): void {
|
||||
const h = this.interestHolding;
|
||||
if (!h) {
|
||||
this.toast('Select a certificate first.');
|
||||
return;
|
||||
}
|
||||
const amt = this.interestAccrued(h);
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'FD',
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`,
|
||||
txnDate: this.asOnDate,
|
||||
amount: amt,
|
||||
particulars: `Interest posted - ${h.party} (${h.interestFreq})`
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Interest of ₹${amt.toLocaleString('en-IN')} posted 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()}`;
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user