Files
cbsnexora/src/app/investment/bond-investment/bond-investment.component.ts
T
niteshandClaude Sonnet 5 ebe67cc410 feat(investment,npa): RBI-grounded targets, GL-tracked interest posting, date/search pickers
- 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>
2026-08-02 19:26:19 +05:30

1212 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 {
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
MUTUAL_FUND_HOLDINGS,
ALL_TXNS,
SCRIPT_MASTER,
PARTY_MASTER,
SUBTYPE_MASTER,
BROKER_MASTER,
SGL_BANK_MASTER,
MUTUAL_FUND_PRODUCTS,
LIEN_REASON_MASTER,
CATEGORY_OPTIONS,
DEAL_TYPE_OPTIONS,
MARKET_OPTIONS,
PAYMENT_MODE_OPTIONS,
DELIVERY_MODE_OPTIONS,
BOND_RATING_OPTIONS,
INT_TYPE_OPTIONS,
AUTHORITY_OPTIONS,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
InterestFreq,
INSTRUMENT_LABELS,
accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity,
maturityAmount,
addPeriod,
approxYtm,
yearsBetween,
todayDDMMYYYY
} from '../investment-data';
import { exportWorkbook } from '../investment-export';
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
type BondTab =
| 'holdings'
| 'purchase'
| 'redemption'
| 'interest'
| 'sell'
| 'writeoff'
| 'amortize'
| 'amortizeYearly'
| 'goiTransfer'
| 'depreciation'
| 'mfTransfer';
interface BondPurchaseForm {
bondType: BondType;
dealNo: string;
dealDate: string;
dealTime: string;
isin: string;
scriptCode: string;
certNo: string;
settlementType: 'T+0' | 'T+1' | 'T+2';
settlementDt: string;
faceValue: number | null;
purchaseRate: number | null;
partyCode: string;
subType: string;
tbillGlAc: string;
crToAc: string;
premiumAmt: number | null;
brokerage: number | null;
brokerName: string;
tradeNo: string;
orderNo: string;
recommAuthority: string;
sanctionAuthority: string;
issueDate: string;
periodYear: number | null;
periodMonth: number | null;
periodDays: number | null;
descInv: string;
folioNo: string;
callOption: string;
meetDt: string;
resoNo: string;
fromDist: string;
toDist: string;
redumMatDt: string;
category: string;
intRate: number | null;
intType: InterestFreq;
intDate: string;
lastIntDt: string;
dealType: string;
market: string;
markCblo: 'YES' | 'NO';
amortizeAmt: number | null;
remarks: string;
lien: 'YES' | 'NO';
lienAmount: number | null;
lienReason: string;
deliveryMode: string;
bondRating: string;
corpBond: 'YES' | 'NO';
paymentMode: string;
sglWithBank: string;
description: string;
registerAdd: string;
regiCity: string;
regiPin: string;
// Mutual Fund specific
productName: string;
considerAmt: number | null;
tradeAmt: number | null;
nav: number | null;
holdingDays: number | null;
stampDuty: number | null;
stampDutyAc: string;
}
interface BondSellForm {
/** Legacy screen has SELL / REDEEM / MUTUAL FUND SELL radio at the top. */
mode: 'SELL' | 'REDEEM' | 'MUTUAL_FUND_SELL';
/** Purchase Search — locate the original purchase by Deal No or ISIN No. */
searchBy: 'DEAL_NO' | 'ISIN_NO';
searchValue: string;
bondType: BondType;
scriptCode: string;
certNo: string;
buyerCode: string;
subType: string;
isin: string;
tbillGlAc: string;
dealDate: string;
dealTime: string;
settlementType: 'T+0' | 'T+1' | 'T+2';
settlementDt: string;
faceValue: number | null;
sellRate: number | null;
tdsAmt: number | null;
unitVal: number | null;
payAmt: number | null;
sellUnit: number | null;
brokerage: number | null;
dbToAc: string;
amortizeAmt: number | null;
recommAuthority: string;
sanctionAuthority: string;
callMoney: boolean;
}
/**
* Combined Govt Security / Non-SLR / Bond / Mutual Fund treasury screen, mirroring the legacy
* "03 Govt./Bond Entry" submenu in full: Purchase, Redemption, Interest Calculation, Sell,
* Write-off, Amortize, Amortize Yearly Process, GOI Category Transfer, Investment Depreciation
* and Mutual Fund Transfer. Dummy in-memory data; Save appends to the local arrays.
*/
@Component({
selector: 'mifosx-bond-investment',
templateUrl: './bond-investment.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class BondInvestmentComponent {
activeTab: BondTab = 'holdings';
bondType: BondType = 'GOVT_SECURITY';
asOnDate = todayDDMMYYYY();
holdings: InvestmentHolding[] = [
...GOVT_SECURITY_HOLDINGS,
...NON_SLR_HOLDINGS,
...BOND_HOLDINGS,
...MUTUAL_FUND_HOLDINGS
];
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => ([
'GOVT_SECURITY',
'NON_SLR',
'BOND',
'MUTUAL_FUND'
] as InstrumentType[]).includes(t.instrumentType));
// Master-driven dropdown sources
readonly scriptOptions = SCRIPT_MASTER;
readonly partyOptions = PARTY_MASTER;
readonly subTypeOptions = SUBTYPE_MASTER;
readonly brokerOptions = BROKER_MASTER;
readonly sglBankOptions = SGL_BANK_MASTER;
readonly mfProductOptions = MUTUAL_FUND_PRODUCTS;
readonly lienReasonOptions = LIEN_REASON_MASTER;
readonly categoryOptions = CATEGORY_OPTIONS;
readonly dealTypeOptions = DEAL_TYPE_OPTIONS;
readonly marketOptions = MARKET_OPTIONS;
readonly paymentModeOptions = PAYMENT_MODE_OPTIONS;
readonly deliveryModeOptions = DELIVERY_MODE_OPTIONS;
readonly bondRatingOptions = BOND_RATING_OPTIONS;
readonly intTypeOptions = INT_TYPE_OPTIONS;
readonly authorityOptions = AUTHORITY_OPTIONS;
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
purchaseForm: BondPurchaseForm = this.blankPurchaseForm();
sellForm: BondSellForm = this.blankSellForm();
// Redemption Entry
redeemCertNo = '';
redeemDate = this.asOnDate;
redeemRemarks = '';
// Interest Calculation (legacy: From/To Date / Type / Script / Deal No / ALL + Process) —
// posts interest accrued strictly within [intCalcFromDate, intCalcToDate], same period-based
// basis as the Investment Interest module's Govt./Bond Interest Calculation.
intCalcFromDate = addPeriod(this.asOnDate, 0, -3);
intCalcToDate = this.asOnDate;
intCalcType: BondType | '' = '';
intCalcScript = '';
intCalcDealNo = '';
intCalcAll = false;
intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
// Write-off
writeoffCertNo = '';
writeoffReason = '';
// Amortize
amortizeCertNo = '';
amortizeAmt: number | null = null;
// Amortize Yearly Process
amortizeYear = String(new Date().getFullYear());
// GOI Category Transfer
goiCertNo = '';
goiFromCategory = '';
goiToCategory = '';
goiTransferDate = this.asOnDate;
// Investment Depreciation
depreciationDate = this.asOnDate;
depreciationCategory = '';
// Mutual Fund Transfer
mfFromCertNo = '';
mfToProduct = '';
mfTransferUnits: number | null = null;
mfTransferDate = this.asOnDate;
readonly bondTypeOptions: Array<{ value: BondType; label: string }> = [
{ value: 'GOVT_SECURITY', label: 'GOVT SECURITY' },
{ value: 'NON_SLR', label: 'NON SLR' },
{ value: 'BOND', label: 'BOND' },
{ value: 'MUTUAL_FUND', label: 'MUTUAL FUND' }
];
setTab(tab: BondTab): void {
this.activeTab = tab;
}
setBondType(type: BondType): void {
this.bondType = type;
this.purchaseForm.bondType = type;
}
get holdingsForType(): InvestmentHolding[] {
return this.holdings.filter((h) => h.instrumentType === this.bondType);
}
get activeHoldingsForType(): InvestmentHolding[] {
return this.holdingsForType.filter((h) => h.status === 'ACTIVE');
}
get txnsForType(): InvestmentTxn[] {
return this.txns.filter((t) => t.instrumentType === this.bondType);
}
get scriptsForType(): typeof SCRIPT_MASTER {
return this.scriptOptions.filter((s) => s.instrumentType === this.purchaseForm.bondType);
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
}
private nextDealNo(): string {
return `2026${String(27000039 + this.holdings.length).padStart(9, '0')}`;
}
private blankPurchaseForm(): BondPurchaseForm {
const type = this.bondType ?? 'GOVT_SECURITY';
return {
bondType: type,
dealNo: this.nextDealNo(),
dealDate: this.asOnDate,
dealTime: '',
isin: '',
scriptCode: '',
certNo: '',
settlementType: 'T+0',
settlementDt: this.asOnDate,
faceValue: null,
purchaseRate: 100,
partyCode: '',
subType: type === 'GOVT_SECURITY' ? 'SLR' : type === 'NON_SLR' ? 'NON SLR' : 'CORPORATE BOND',
tbillGlAc: '',
crToAc: '310070',
premiumAmt: null,
brokerage: null,
brokerName: '',
tradeNo: '',
orderNo: '',
recommAuthority: '',
sanctionAuthority: '',
issueDate: this.asOnDate,
periodYear: 3,
periodMonth: 0,
periodDays: 0,
descInv: '',
folioNo: '',
callOption: '',
meetDt: '',
resoNo: '',
fromDist: '',
toDist: '',
redumMatDt: '',
category: '',
intRate: null,
intType: 'HALF_YEARLY',
intDate: this.asOnDate,
lastIntDt: this.asOnDate,
dealType: 'OUTRIGHT',
market: 'NDS-OM',
markCblo: 'NO',
amortizeAmt: null,
remarks: '',
lien: 'NO',
lienAmount: null,
lienReason: '',
deliveryMode: '',
bondRating: type === 'GOVT_SECURITY' ? 'SOVEREIGN' : 'AAA',
corpBond: type === 'BOND' ? 'YES' : 'NO',
paymentMode: '',
sglWithBank: '',
description: '',
registerAdd: '',
regiCity: '',
regiPin: '',
productName: '',
considerAmt: null,
tradeAmt: null,
nav: null,
holdingDays: null,
stampDuty: null,
stampDutyAc: ''
};
}
private blankSellForm(): BondSellForm {
return {
mode: 'SELL',
searchBy: 'DEAL_NO',
searchValue: '',
bondType: this.bondType ?? 'GOVT_SECURITY',
scriptCode: '',
certNo: '',
buyerCode: '',
subType: '',
isin: '',
tbillGlAc: '',
dealDate: this.asOnDate,
dealTime: '',
settlementType: 'T+0',
settlementDt: this.asOnDate,
faceValue: null,
sellRate: null,
tdsAmt: null,
unitVal: 100,
payAmt: null,
sellUnit: null,
brokerage: null,
dbToAc: '310070',
amortizeAmt: null,
recommAuthority: '',
sanctionAuthority: '',
callMoney: false
};
}
get isMutualFund(): boolean {
return this.purchaseForm.bondType === 'MUTUAL_FUND';
}
/** Face value for the purchase — Mutual Fund screens label it "Consider Amt" instead. */
get purchaseBaseAmount(): number {
const f = this.purchaseForm;
return (this.isMutualFund ? f.considerAmt : f.faceValue) || 0;
}
get purchaseMaturityDate(): string {
const f = this.purchaseForm;
if (f.redumMatDt) {
return f.redumMatDt;
}
if (!f.issueDate) {
return '';
}
return addPeriod(f.issueDate, f.periodYear || 0, f.periodMonth || 0, f.periodDays || 0);
}
get purchaseUnits(): number {
const f = this.purchaseForm;
const amt = this.purchaseBaseAmount;
if (this.isMutualFund) {
return f.nav ? +(amt / f.nav).toFixed(3) : 0;
}
return f.purchaseRate ? Math.round((amt / f.purchaseRate) * (f.purchaseRate / 100)) : 0;
}
/** Unit Val — the per-unit face value used by the legacy form (100 for G-Sec/Bonds, NAV for MF). */
get purchaseUnitVal(): number {
return this.isMutualFund ? this.purchaseForm.nav || 0 : 100;
}
/** Book Value — what the investment is carried at, i.e. amount actually paid. */
get purchaseBookValue(): number {
const f = this.purchaseForm;
if (this.isMutualFund) {
return this.purchaseBaseAmount + (f.stampDuty || 0) + (f.brokerage || 0);
}
const rate = f.purchaseRate || 100;
return Math.round((this.purchaseBaseAmount * rate) / 100) + (f.brokerage || 0);
}
get purchaseYtm(): number {
const f = this.purchaseForm;
if (!f.intRate || !this.purchaseMaturityDate) {
return 0;
}
return approxYtm(f.intRate, f.purchaseRate || 100, yearsBetween(f.issueDate, this.purchaseMaturityDate));
}
/** Accrued Interest at purchase — interest earned by the seller since the last coupon date. */
get purchaseAccruedInterest(): number {
const f = this.purchaseForm;
if (!f.intRate || !f.lastIntDt || !f.dealDate) {
return 0;
}
const yrs = Math.max(0, yearsBetween(f.lastIntDt, f.dealDate));
return Math.round(this.purchaseBaseAmount * (f.intRate / 100) * yrs);
}
/** Settlement Amt of Purchase — book value plus accrued interest payable to the seller. */
get purchaseSettlementAmount(): number {
return this.purchaseBookValue + this.purchaseAccruedInterest;
}
get purchaseMaturityAmount(): number {
const f = this.purchaseForm;
if (!this.purchaseBaseAmount || !this.purchaseMaturityDate) {
return this.purchaseBaseAmount;
}
return maturityAmount({
certNo: f.certNo,
instrumentType: f.bondType,
scriptCode: f.scriptCode,
scriptName: '',
party: f.partyCode,
subType: f.subType,
faceValue: this.purchaseBaseAmount,
purchaseRate: f.purchaseRate || 100,
units: this.purchaseUnits,
purchaseDate: f.issueDate,
maturityDate: this.purchaseMaturityDate,
interestRate: f.intRate || 0,
interestFreq: this.isMutualFund ? 'CUMULATIVE' : f.intType,
glCode: '',
glName: '',
crToAc: f.crToAc,
brokerCode: f.brokerName,
status: 'ACTIVE'
});
}
/** Fills ISIN + interest rate when a script is picked, mirroring the legacy form's lookup. */
onScriptChange(): void {
const s = this.scriptOptions.find((x) => x.scriptCode === this.purchaseForm.scriptCode);
if (s) {
this.purchaseForm.isin = s.isin;
}
}
savePurchase(): void {
const f = this.purchaseForm;
if (!f.certNo || !this.purchaseBaseAmount || !f.partyCode) {
this.toast('Please fill Cert No, Face Value / Consider Amt and Party Code.');
return;
}
const holding: InvestmentHolding = {
certNo: f.certNo,
instrumentType: f.bondType,
scriptCode: f.scriptCode || f.isin || f.productName,
scriptName: this.isMutualFund
? f.productName
: this.scriptOptions.find((s) => s.scriptCode === f.scriptCode)?.scriptName || f.scriptCode || f.isin,
party: f.partyCode,
subType: f.subType,
faceValue: this.purchaseBaseAmount,
purchaseRate: this.isMutualFund ? f.nav || 0 : f.purchaseRate || 100,
units: this.purchaseUnits,
purchaseDate: f.issueDate,
maturityDate: this.purchaseMaturityDate || f.issueDate,
interestRate: f.intRate || 0,
interestFreq: this.isMutualFund ? 'CUMULATIVE' : f.intType,
glCode:
'31' +
(f.bondType === 'GOVT_SECURITY'
? '0020'
: f.bondType === 'NON_SLR'
? '0030'
: f.bondType === 'BOND'
? '0040'
: '0050'),
glName: `INVESTMENT IN ${INSTRUMENT_LABELS[f.bondType].toUpperCase()}`,
crToAc: f.crToAc,
brokerCode: f.brokerName,
status: 'ACTIVE'
};
this.holdings = [
holding,
...this.holdings
];
this.txns = [
{
certNo: holding.certNo,
instrumentType: f.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${f.dealNo}`,
txnDate: f.dealDate,
amount: this.purchaseSettlementAmount,
particulars: `Purchase Deal ${f.dealNo} - ${holding.scriptName} - ${holding.party}${f.category ? ' [' + f.category + ']' : ''}`
},
...this.txns
];
this.toast(`${INSTRUMENT_LABELS[f.bondType]} Certificate ${holding.certNo} saved successfully.`);
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
}
cancelPurchase(): void {
this.purchaseForm = this.blankPurchaseForm();
this.toast('Entry cancelled — form reset.');
}
/** Legacy "Retrieve" button — loads an existing deal back into the form by Cert No. */
retrievePurchase(): void {
const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo.trim());
if (!h) {
this.toast(`Cert No ${this.purchaseForm.certNo} not found.`);
return;
}
this.purchaseForm = {
...this.blankPurchaseForm(),
bondType: h.instrumentType as BondType,
certNo: h.certNo,
scriptCode: h.scriptCode,
isin: this.scriptOptions.find((s) => s.scriptCode === h.scriptCode)?.isin || '',
partyCode: h.party,
subType: h.subType,
faceValue: h.faceValue,
considerAmt: h.faceValue,
purchaseRate: h.purchaseRate,
nav: h.purchaseRate,
issueDate: h.purchaseDate,
redumMatDt: h.maturityDate,
intRate: h.interestRate,
intType: h.interestFreq,
brokerName: h.brokerCode,
crToAc: h.crToAc,
productName: h.scriptName
};
this.bondType = h.instrumentType as BondType;
this.toast(`Deal for Cert No ${h.certNo} retrieved.`);
}
dropPurchase(): void {
const certNo = this.purchaseForm.certNo;
if (!this.holdings.some((h) => h.certNo === certNo)) {
this.toast('Nothing to drop — this deal 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(`Cert No ${certNo} dropped.`);
}
exitPurchase(): void {
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
}
// -------------------- Redemption Entry --------------------
get redeemHolding(): InvestmentHolding | undefined {
return this.holdingsForType.find((h) => h.certNo === this.redeemCertNo);
}
get redeemAmount(): number {
const h = this.redeemHolding;
return h ? maturityAmount(h) + accruedInterest(h, this.redeemDate) - h.faceValue + h.faceValue : 0;
}
saveRedemption(): void {
const h = this.redeemHolding;
if (!h) {
this.toast('Select a certificate to redeem.');
return;
}
h.status = 'MATURED';
this.txns = [
{
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'REDEEM',
voucherNo: `INV/RED/${h.certNo}`,
txnDate: this.redeemDate,
amount: this.redeemAmount,
particulars: `Redemption on maturity - ${h.scriptName}${this.redeemRemarks ? ' - ' + this.redeemRemarks : ''}`
},
...this.txns
];
this.toast(`${h.certNo} redeemed. Proceeds ₹${this.redeemAmount.toLocaleString('en-IN')}.`);
this.redeemCertNo = '';
this.redeemRemarks = '';
this.activeTab = 'holdings';
}
// -------------------- Interest calculation --------------------
/** Holdings the Interest Calculation "Process" run would cover, given the filters. A
* Non-Performing holding is always excluded — same accrual-basis rule as the Investment
* Interest module (it's still collectible on cash basis via Interest Receive there). */
get intCalcTargets(): InvestmentHolding[] {
const base = this.holdings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.intCalcAll) {
return base;
}
return base.filter((h) => {
if (this.intCalcType && h.instrumentType !== this.intCalcType) return false;
if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false;
if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false;
return true;
});
}
get intCalcExcludedNpi(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && !canAccrueInterest(h));
}
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
onIntCalcTypeChange(): void {
if (this.intCalcType) {
this.intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.intCalcType];
}
}
glName(glCode: string): string {
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
}
/** Interest accrued strictly within [intCalcFromDate, intCalcToDate] — a period-based
* Interest Calculation & Post run, same basis as the Investment Interest module. */
interestAccrued(h: InvestmentHolding): number {
return accruedInterestBetween(h, this.intCalcFromDate || h.purchaseDate, this.intCalcToDate || this.asOnDate);
}
processInterest(): void {
const targets = this.intCalcTargets.filter((h) => this.interestAccrued(h) > 0);
if (targets.length === 0) {
this.toast('No holdings have interest due to be posted for this period.');
return;
}
const toDate = this.intCalcToDate || this.asOnDate;
const fromDate = this.intCalcFromDate || toDate;
const glCode = this.intCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.interestAccrued(h);
h.interestPosted = (h.interestPosted ?? 0) + amt;
h.interestPostedTillDate = toDate;
return {
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
txnDate: toDate,
amount: amt,
particulars: `Interest calculated (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
glCode,
glName
};
});
this.txns = [
...newTxns,
...this.txns
];
this.intCalcFromDate = toDate;
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.toast(
`Interest processed for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
}
// -------------------- Sell / Redeem Entry --------------------
/** Locates the original purchase by Deal No / Cert No or ISIN and fills the sell form. */
searchPurchaseForSell(): void {
const val = this.sellForm.searchValue.trim();
if (!val) {
this.toast('Enter a Deal No / Cert No or ISIN to search.');
return;
}
const h =
this.sellForm.searchBy === 'DEAL_NO'
? this.holdings.find((x) => x.certNo === val)
: this.holdings.find((x) => this.scriptOptions.find((s) => s.scriptCode === x.scriptCode)?.isin === val);
if (!h) {
this.toast('No matching purchase found.');
return;
}
this.sellForm = {
...this.sellForm,
bondType: h.instrumentType as BondType,
scriptCode: h.scriptCode,
certNo: h.certNo,
subType: h.subType,
isin: this.scriptOptions.find((s) => s.scriptCode === h.scriptCode)?.isin || '',
faceValue: h.faceValue,
unitVal: h.instrumentType === 'MUTUAL_FUND' ? h.purchaseRate : 100,
sellUnit: h.units
};
this.toast(`Purchase ${h.certNo} loaded.`);
}
get sellHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.sellForm.certNo);
}
/** Consideration = units sold × sell rate, scaled by the instrument's unit convention. */
get sellConsideration(): number {
const f = this.sellForm;
if (!f.sellUnit || !f.sellRate) {
return 0;
}
const perUnitDivisor = f.mode === 'MUTUAL_FUND_SELL' ? 1 : 100;
return Math.round((f.sellUnit * f.sellRate) / perUnitDivisor);
}
/** Sale Diff — profit/loss versus what the holding was bought at. */
get sellSaleDiff(): number {
const h = this.sellHolding;
const f = this.sellForm;
if (!h || !f.sellUnit || !f.sellRate) {
return 0;
}
const perUnitDivisor = f.mode === 'MUTUAL_FUND_SELL' ? 1 : 100;
const cost = Math.round((f.sellUnit * h.purchaseRate) / perUnitDivisor);
return this.sellConsideration - cost;
}
/** Premium Loss — the unamortised premium written off when a holding is sold early. */
get sellPremiumLoss(): number {
const h = this.sellHolding;
if (!h || h.purchaseRate <= 100 || h.instrumentType === 'MUTUAL_FUND') {
return 0;
}
return Math.round((h.faceValue * (h.purchaseRate - 100)) / 100);
}
get sellAccruedInterest(): number {
const h = this.sellHolding;
return h ? accruedInterest(h, this.sellForm.dealDate || this.asOnDate) : 0;
}
get sellAccruedDays(): number {
const h = this.sellHolding;
if (!h) {
return 0;
}
return Math.max(0, Math.round(yearsBetween(h.purchaseDate, this.sellForm.dealDate || this.asOnDate) * 365));
}
/** Pay Amt — net amount receivable: consideration + accrued interest − TDS − brokerage. */
get sellPayAmount(): number {
const f = this.sellForm;
return this.sellConsideration + this.sellAccruedInterest - (f.tdsAmt || 0) - (f.brokerage || 0);
}
saveSell(): void {
const f = this.sellForm;
const h = this.sellHolding;
if (!h || !f.sellRate || !f.sellUnit) {
this.toast('Load a purchase, then enter Sell Rate and Sell Unit.');
return;
}
if (f.sellUnit > h.units) {
this.toast(`Sell Unit cannot exceed units held (${h.units}).`);
return;
}
h.units -= f.sellUnit;
h.faceValue = Math.round(h.units * (f.unitVal || 100));
if (h.units <= 0) {
h.status = f.mode === 'REDEEM' ? 'MATURED' : 'SOLD';
}
this.txns = [
{
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: f.mode === 'REDEEM' ? 'REDEEM' : 'SELL',
voucherNo: `INV/${f.mode === 'REDEEM' ? 'RED' : 'SEL'}/${h.certNo}-${this.txns.length + 1}`,
txnDate: f.dealDate,
amount: this.sellPayAmount,
particulars: `${f.mode.replace(/_/g, ' ')} - ${h.scriptName} @ ${f.sellRate}${f.buyerCode ? ' to ' + f.buyerCode : ''}${f.callMoney ? ' [CALL MONEY]' : ''}`
},
...this.txns
];
this.toast(
`${h.certNo} ${f.mode === 'REDEEM' ? 'redeemed' : 'sold'}. Pay Amt ₹${this.sellPayAmount.toLocaleString('en-IN')}.`
);
this.sellForm = this.blankSellForm();
this.activeTab = 'holdings';
}
cancelSell(): void {
this.sellForm = this.blankSellForm();
this.toast('Sell entry cancelled — form reset.');
}
exitSell(): void {
this.sellForm = this.blankSellForm();
this.activeTab = 'holdings';
}
// -------------------- Write-off --------------------
get writeoffHolding(): InvestmentHolding | undefined {
return this.holdingsForType.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: h.instrumentType,
txnType: 'WRITE_OFF',
voucherNo: `INV/WOF/${h.certNo}`,
txnDate: this.asOnDate,
amount: h.faceValue,
particulars: `Written off - ${this.writeoffReason}`
},
...this.txns
];
this.toast(`${h.certNo} written off.`);
this.writeoffCertNo = '';
this.writeoffReason = '';
this.activeTab = 'holdings';
}
// -------------------- Amortize --------------------
get amortizeHolding(): InvestmentHolding | undefined {
return this.holdingsForType.find((h) => h.certNo === this.amortizeCertNo);
}
/** Straight-line premium amortisation for one year on the selected holding. */
get suggestedAmortizeAmt(): number {
const h = this.amortizeHolding;
if (!h || h.purchaseRate <= 100) {
return 0;
}
const years = Math.max(1, yearsBetween(h.purchaseDate, h.maturityDate));
return Math.round((h.faceValue * (h.purchaseRate - 100)) / 100 / years);
}
saveAmortize(): void {
const h = this.amortizeHolding;
const amt = this.amortizeAmt ?? this.suggestedAmortizeAmt;
if (!h || !amt) {
this.toast('Select a certificate and enter the amortization amount.');
return;
}
this.txns = [
{
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'AMORTIZE',
voucherNo: `INV/AMZ/${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate,
amount: amt,
particulars: `Premium/discount amortized - ${h.scriptName}`
},
...this.txns
];
this.toast(`Amortization of ₹${amt.toLocaleString('en-IN')} booked for ${h.certNo}.`);
this.amortizeCertNo = '';
this.amortizeAmt = null;
}
// -------------------- Amortize Yearly Process --------------------
/** All premium-carrying holdings the yearly batch would amortise. */
get amortizeYearlyTargets(): Array<{ holding: InvestmentHolding; amount: number }> {
return this.holdings
.filter((h) => h.status === 'ACTIVE' && h.purchaseRate > 100 && h.instrumentType !== 'MUTUAL_FUND')
.map((h) => {
const years = Math.max(1, yearsBetween(h.purchaseDate, h.maturityDate));
return { holding: h, amount: Math.round((h.faceValue * (h.purchaseRate - 100)) / 100 / years) };
});
}
get amortizeYearlyTotal(): number {
return this.amortizeYearlyTargets.reduce((s, t) => s + t.amount, 0);
}
runAmortizeYearly(): void {
const targets = this.amortizeYearlyTargets;
if (targets.length === 0) {
this.toast('No premium-carrying holdings to amortise.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((t, i) => ({
certNo: t.holding.certNo,
instrumentType: t.holding.instrumentType,
txnType: 'AMORTIZE',
voucherNo: `INV/AMZ-YR/${this.amortizeYear}-${i + 1}`,
txnDate: this.asOnDate,
amount: t.amount,
particulars: `Yearly amortization process ${this.amortizeYear} - ${t.holding.scriptName}`
}));
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`Yearly amortization run for ${targets.length} holding(s). Total ₹${this.amortizeYearlyTotal.toLocaleString('en-IN')}.`
);
}
// -------------------- GOI Category Transfer --------------------
get goiHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.goiCertNo);
}
saveGoiTransfer(): void {
const h = this.goiHolding;
if (!h || !this.goiFromCategory || !this.goiToCategory) {
this.toast('Select a certificate and both From / To categories.');
return;
}
if (this.goiFromCategory === this.goiToCategory) {
this.toast('From and To categories must differ.');
return;
}
this.txns = [
{
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'AMORTIZE',
voucherNo: `INV/GOI-TRF/${h.certNo}-${this.txns.length + 1}`,
txnDate: this.goiTransferDate,
amount: h.faceValue,
particulars: `GOI category transfer ${this.goiFromCategory} → ${this.goiToCategory} - ${h.scriptName}`
},
...this.txns
];
this.toast(`${h.certNo} transferred from ${this.goiFromCategory} to ${this.goiToCategory}.`);
this.goiCertNo = '';
this.goiFromCategory = '';
this.goiToCategory = '';
}
// -------------------- Investment Depreciation --------------------
/** AFS/HFT holdings whose market price is below book value must be marked down (RBI norms). */
get depreciationTargets(): Array<{ holding: InvestmentHolding; marketPrice: number; depreciation: number }> {
return this.holdings
.filter((h) => h.status === 'ACTIVE' && h.instrumentType !== 'MUTUAL_FUND')
.map((h) => {
const md = this.scriptOptions.find((s) => s.scriptCode === h.scriptCode);
const marketPrice = md ? h.purchaseRate * 0.995 : h.purchaseRate;
const depreciation =
marketPrice < h.purchaseRate ? Math.round((h.faceValue * (h.purchaseRate - marketPrice)) / 100) : 0;
return { holding: h, marketPrice: +marketPrice.toFixed(2), depreciation };
})
.filter((r) => r.depreciation > 0);
}
get depreciationTotal(): number {
return this.depreciationTargets.reduce((s, r) => s + r.depreciation, 0);
}
runDepreciation(): void {
const targets = this.depreciationTargets;
if (targets.length === 0) {
this.toast('No holdings require depreciation provisioning.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((t, i) => ({
certNo: t.holding.certNo,
instrumentType: t.holding.instrumentType,
txnType: 'AMORTIZE',
voucherNo: `INV/DEPR/${this.depreciationDate.replace(/-/g, '')}-${i + 1}`,
txnDate: this.depreciationDate,
amount: t.depreciation,
particulars: `Investment depreciation provision - ${t.holding.scriptName} (market ${t.marketPrice})`
}));
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`Depreciation provisioned for ${targets.length} holding(s). Total ₹${this.depreciationTotal.toLocaleString('en-IN')}.`
);
}
// -------------------- Mutual Fund Transfer --------------------
get mfHoldings(): InvestmentHolding[] {
return this.holdings.filter((h) => h.instrumentType === 'MUTUAL_FUND' && h.status === 'ACTIVE');
}
get mfFromHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.mfFromCertNo);
}
get mfTransferValue(): number {
const h = this.mfFromHolding;
if (!h || !this.mfTransferUnits) {
return 0;
}
return Math.round(this.mfTransferUnits * h.purchaseRate);
}
saveMfTransfer(): void {
const h = this.mfFromHolding;
if (!h || !this.mfToProduct || !this.mfTransferUnits) {
this.toast('Select source holding, target product and units to transfer.');
return;
}
if (this.mfTransferUnits > h.units) {
this.toast(`Units cannot exceed units held (${h.units}).`);
return;
}
const value = this.mfTransferValue;
h.units = +(h.units - this.mfTransferUnits).toFixed(3);
h.faceValue = Math.round(h.units * h.purchaseRate);
if (h.units <= 0) {
h.status = 'SOLD';
}
const product = this.mfProductOptions.find((p) => p.productName === this.mfToProduct);
const newCert = `MF-${5000 + this.holdings.filter((x) => x.instrumentType === 'MUTUAL_FUND').length + 1}`;
this.holdings = [
{
certNo: newCert,
instrumentType: 'MUTUAL_FUND',
scriptCode: product?.productCode || this.mfToProduct,
scriptName: this.mfToProduct,
party: product?.amcName || '',
subType: product?.category || 'DEBT',
faceValue: value,
purchaseRate: h.purchaseRate,
units: this.mfTransferUnits,
purchaseDate: this.mfTransferDate,
maturityDate: addPeriod(this.mfTransferDate, 1, 0),
interestRate: h.interestRate,
interestFreq: 'CUMULATIVE',
glCode: '310050',
glName: 'INVESTMENT IN MUTUAL FUNDS',
crToAc: h.crToAc,
brokerCode: '',
status: 'ACTIVE'
},
...this.holdings
];
this.txns = [
{
certNo: newCert,
instrumentType: 'MUTUAL_FUND',
txnType: 'PURCHASE',
voucherNo: `INV/MF-TRF/${newCert}`,
txnDate: this.mfTransferDate,
amount: value,
particulars: `Mutual Fund switch from ${h.scriptName} to ${this.mfToProduct} (${this.mfTransferUnits} units)`
},
...this.txns
];
this.toast(`Transferred ${this.mfTransferUnits} units to ${this.mfToProduct} as ${newCert}.`);
this.mfFromCertNo = '';
this.mfToProduct = '';
this.mfTransferUnits = null;
}
// -------------------- 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: this.instrumentLabel(this.bondType),
title: `${this.instrumentLabel(this.bondType)} Holdings Register`,
subtitle: `As on ${this.asOnDate}`,
headers: [
'Cert No',
'Script',
'Party',
'Units',
'Face Value',
'Rate',
'Purchase Date',
'Maturity Date',
'Accrued Interest',
'Maturity Value',
'Status'
],
widths: [
14,
26,
22,
12,
16,
10,
14,
14,
16,
16,
12
],
rows: this.holdingsForType.map((h) => [
h.certNo,
h.scriptName,
h.party,
h.units,
h.faceValue,
h.interestRate,
h.purchaseDate,
h.maturityDate,
this.accrued(h),
this.maturity(h),
h.status
])
}
],
`${this.instrumentLabel(this.bondType)}-Holdings-${this.asOnDate}`
);
this.toast(`${this.instrumentLabel(this.bondType)} Holdings exported.`);
} catch (err) {
console.error('Bond 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);
}
}