- Correct Investment portfolio allocation and Loan Origination product-mix targets against RBI's UCB SLR/Non-SLR framework and PSL circulars, with a Target Basis column distinguishing RBI-mandated vs board-policy rows (Excel export included). - Expand Investment holdings dataset with more near-term maturities so the Due-Date Diary reflects a realistic spread across FD/Govt Security/Non-SLR/Bond. - Add From/To period-based Interest Calculation & Post (with GL selection for both posting and receiving) across the Investment Interest module, FD Entry and Bond Entry screens; fix a bug where a posted period kept re-appearing as due since accrual wasn't tracked per-holding. - Add a reusable DD-MM-YYYY date-picker component and roll it out across all Investment date fields. - NPA Summary Report: drop SMA-0/1/2 from the stage-wise table (kept in Day-wise Overdue only). - NPA Recovery Report: add Account Type / NPA Category / Target-vs-Actual columns, a shared Account Type filter driving both the account picker and the register, and a searchable (fixed-position, not clipped by card overflow) account select for long lists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
563 lines
17 KiB
TypeScript
563 lines
17 KiB
TypeScript
/**
|
|
* 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<typeof setTimeout> | 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<void> {
|
|
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);
|
|
}
|
|
}
|