Files
cbsnexora/src/app/investment/bond-investment/bond-investment.component.ts
T

1621 lines
51 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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
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,
INVESTMENT_GL_BY_TYPE,
INVESTMENT_GL_LOOKUP,
INTEREST_RECEIVABLE_GL,
BROKEN_PERIOD_INTEREST_GL,
STAMP_DUTY_GL,
BROKERAGE_GL,
OTHER_CHARGES_GL,
SETTLEMENT_BANK_GL_OPTIONS,
periodBetween,
parseDDMMYYYY,
days360,
lastHalfYearlyCouponDate,
nextHalfYearlyCouponDate,
couponDatesLabel,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
InterestFreq,
INSTRUMENT_LABELS,
accruedInterest,
daysToMaturity,
maturityAmount,
addPeriod,
approxYtm,
yearsBetween,
todayDDMMYYYY
} from '../investment-data';
import { exportWorkbook } from '../investment-export';
import {
InvestmentDailyRateDto,
InvestmentInterestDueDto,
InvestmentInterestRowDto,
InvestmentInterestScheduleDto,
InvestmentPurchaseDto,
InvestmentService,
apiErrorMessage,
toHolding
} from '../investment.service';
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;
otherCharges: number | null;
}
interface AccountingPreviewLine {
side: 'Dr' | 'Cr';
glCode: string;
glName: string;
amount: number;
}
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 implements OnInit {
private readonly investmentService = inject(InvestmentService);
private readonly route = inject(ActivatedRoute);
/** Script and party dropdowns offer the masters saved in the backend. */
ngOnInit(): void {
// The home-screen interest alert links here with ?tab=interest.
if (this.route.snapshot.queryParamMap.get('tab') === 'interest') {
this.activeTab = 'interest';
}
this.investmentService.syncSharedMasters().subscribe({
error: (err) => this.toast(`Could not load Script / Party masters: ${apiErrorMessage(err)}`)
});
this.investmentService.getAssetGlAccounts().subscribe({
next: (gls) =>
(this.glIdByCode = new Map(
gls.map((g) => [
g.glCode,
g.id
])
)),
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
});
this.loadPurchases();
this.loadDueInterest();
}
/** GL id for a GL code — the purchase API takes ids, the form works with codes. */
private glIdByCode = new Map<string, number>();
/** The Holdings register and transaction log are the purchases saved in the backend. */
private loadPurchases(): void {
this.investmentService.getPurchases().subscribe({
next: (purchases) => {
this.holdings = purchases.map(toHolding);
this.txns = purchases.map((d) => this.purchaseTxn(d));
this.purchaseForm = this.blankPurchaseForm();
},
error: (err) => this.toast(`Could not load purchases: ${apiErrorMessage(err)}`)
});
}
/** A purchase reaches the GL only when it is settled, dated the settlement date. */
private purchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn {
const posted = !!d.glTransactionId;
return {
certNo: d.certNo,
instrumentType: d.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${d.dealNo}`,
txnDate: d.settlementDate,
amount: d.settlementAmount,
particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]`,
glCode: posted ? d.investmentGlCode : '',
glName: posted ? `${d.investmentGlName} (txn ${d.glTransactionId})` : 'Not posted (pending settlement)'
};
}
activeTab: BondTab = 'holdings';
/** Purchase Entry shows only the mandatory fields; the rest sit under "More Details". */
showMoreDetails = false;
/** Cert No typed in the Retrieve box (Cert No itself is system-generated and read-only). */
retrieveCertNo = '';
bondType: BondType = 'GOVT_SECURITY';
asOnDate = todayDDMMYYYY();
/** Loaded from the backend (saved purchases) in ngOnInit. */
holdings: InvestmentHolding[] = [];
txns: InvestmentTxn[] = [];
// 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();
readonly settlementBankOptions = SETTLEMENT_BANK_GL_OPTIONS;
// Redemption Entry
redeemCertNo = '';
redeemDate = this.asOnDate;
redeemRemarks = '';
// 03 Interest Entry (legacy: Type / Script / Deal No + Retrieve) — the backend works out every
// half-yearly coupon and 31 Mar / 30 Sep closing provision of the holding. Display only:
// nothing is posted to GL until the posting rules are approved.
intType: BondType | '' = '';
intScript = '';
intPurchaseId: number | null = null;
intAsOnDate = this.asOnDate;
intSchedule: InvestmentInterestScheduleDto | null = null;
intLoading = false;
/** Interest lines of all holdings due now or within two days — what Jitendra Sir asked to be reminded of. */
intDueTasks: InvestmentInterestDueDto[] = [];
/** By default the schedule shows only lines up to the end of next year (and anything already posted). */
intShowFullSchedule = false;
/** Bank a coupon is received into; defaults to the purchase's paying bank. */
intBankGlCode = '';
intPosting = false;
readonly interestReceivableGl = INTEREST_RECEIVABLE_GL;
// 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];
}
/** Preview of the deal number the backend will assign: FY prefix (e.g. 202627) + running number. */
private nextDealNo(): string {
const d = parseDDMMYYYY(this.asOnDate);
const fyStart = d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
const prefix = `${fyStart}${String((fyStart + 1) % 100).padStart(2, '0')}`;
const nums = (this.holdings ?? [])
.map((h) => h.dealNo)
.filter((n): n is string => !!n && n.startsWith(prefix))
.map((n) => parseInt(n.slice(prefix.length), 10));
return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(6, '0')}`;
}
/** Certificate No is system-generated, as in the existing CBS (greyed there). */
/** Preview of the certificate number the backend will assign (running number). */
private nextCertNo(): string {
const nums = (this.holdings ?? []).map((h) => parseInt(h.certNo, 10)).filter((n) => !isNaN(n));
return String((nums.length ? Math.max(...nums) : 0) + 1);
}
private blankPurchaseForm(): BondPurchaseForm {
const type = this.bondType ?? 'GOVT_SECURITY';
this.showMoreDetails = false;
this.retrieveCertNo = '';
this.purchaseMarketRate = undefined;
return {
bondType: type,
dealNo: this.nextDealNo(),
dealDate: this.asOnDate,
dealTime: new Date().toTimeString().slice(0, 8),
isin: '',
scriptCode: '',
certNo: this.nextCertNo(),
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: SETTLEMENT_BANK_GL_OPTIONS[0]?.glCode ?? '',
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: '',
otherCharges: null
};
}
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 — the price paid for the security. Charges are expenses and stay out of it (Jitendra Sir). */
get purchaseBookValue(): number {
const f = this.purchaseForm;
if (this.isMutualFund) {
return this.purchaseBaseAmount;
}
const rate = f.purchaseRate || 100;
return Math.round((this.purchaseBaseAmount * rate) / 100);
}
/** Stamp duty + brokerage + other charges — paid with the settlement, booked to expense GLs. */
get purchaseTotalCharges(): number {
const f = this.purchaseForm;
return (f.stampDuty || 0) + (f.brokerage || 0) + (f.otherCharges || 0);
}
// Interest cycle of the security being bought: its two coupon days and the next coupon after the deal.
get purchaseCouponDates(): string {
return this.purchaseMaturityDate ? couponDatesLabel(this.purchaseMaturityDate) : '';
}
get purchaseNextCouponDate(): string {
const f = this.purchaseForm;
return this.purchaseMaturityDate && f.dealDate
? nextHalfYearlyCouponDate(this.purchaseMaturityDate, f.dealDate)
: '';
}
get purchaseNextCouponAmount(): number {
const f = this.purchaseForm;
return f.intRate ? Math.round((this.purchaseBaseAmount * f.intRate) / 200) : 0;
}
get purchaseDaysToNextCoupon(): number {
return this.purchaseNextCouponDate ? days360(this.purchaseForm.dealDate, this.purchaseNextCouponDate) : 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));
}
/** Broken-period interest owed to the seller from the last coupon date up to the settlement date, 30/360. */
get purchaseAccruedInterest(): number {
const f = this.purchaseForm;
if (!f.intRate || !f.lastIntDt || !f.dealDate) {
return 0;
}
return Math.round((this.purchaseBaseAmount * (f.intRate / 100) * this.purchaseAccruedDays) / 360);
}
get purchaseAccruedDays(): number {
const f = this.purchaseForm;
// Up to the settlement date, when the bond leaves the seller (RBI para 83; Jitendra Sir).
return f.lastIntDt && this.purchaseSettlementDate ? days360(f.lastIntDt, this.purchaseSettlementDate) : 0;
}
/** Premium (+) or discount (−) paid over face value. */
get purchasePremiumDiscount(): number {
const f = this.purchaseForm;
if (this.isMutualFund || !f.faceValue || !f.purchaseRate) {
return 0;
}
return Math.round((f.faceValue * (f.purchaseRate - 100)) / 100);
}
/** Re-suggests the last coupon date when the deal date changes. */
onDealDateChange(): void {
this.suggestLastIntDate();
this.loadMarketRate();
}
private suggestLastIntDate(): void {
const f = this.purchaseForm;
const s = this.purchaseScript;
if (!this.isMutualFund && s?.maturityDate && f.dealDate) {
f.lastIntDt = lastHalfYearlyCouponDate(s.maturityDate, f.dealDate) || f.lastIntDt;
}
}
/** Settlement Amt of Purchase — book value, accrued interest payable to the seller and the charges. */
get purchaseSettlementAmount(): number {
return this.purchaseBookValue + this.purchaseAccruedInterest + this.purchaseTotalCharges;
}
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'
});
}
get purchaseScript(): (typeof SCRIPT_MASTER)[number] | undefined {
return this.scriptOptions.find((x) => x.scriptCode === this.purchaseForm.scriptCode);
}
/** Picking a script pulls its master details into the deal, as the existing CBS does. */
onScriptChange(): void {
const s = this.purchaseScript;
if (!s) {
return;
}
const f = this.purchaseForm;
f.isin = s.isin;
if (s.couponRate != null) {
f.intRate = s.couponRate;
}
if (s.issueDate) {
f.issueDate = s.issueDate;
}
if (s.maturityDate) {
f.redumMatDt = s.maturityDate;
const p = periodBetween(s.issueDate, s.maturityDate);
f.periodYear = p.years;
f.periodMonth = p.months;
f.periodDays = p.days;
}
if (s.slrType) {
f.subType = s.slrType === 'NON_SLR' ? 'NON SLR' : 'SLR';
}
if (this.isMutualFund) {
f.productName = s.scriptName;
}
this.suggestLastIntDate();
this.loadMarketRate();
}
/**
* Market rate of the script for the deal date from the Daily Script Master (newest rate on or before it). It fills
* the purchase rate (NAV for a mutual fund); the dealer may still change it. undefined = not looked up yet,
* null = no rate entered for the script.
*/
purchaseMarketRate: InvestmentDailyRateDto | null | undefined;
private loadMarketRate(): void {
const s = this.purchaseScript;
const f = this.purchaseForm;
if (!s?.id || !f.dealDate) {
this.purchaseMarketRate = undefined;
return;
}
this.investmentService.getLatestDailyRate(s.id, f.dealDate).subscribe({
next: (rate) => {
this.purchaseMarketRate = rate;
// No market rate: clear the field rather than leave another script's rate in it.
if (this.isMutualFund) {
f.nav = rate ? rate.closeRate : null;
} else {
f.purchaseRate = rate ? rate.closeRate : null;
}
},
error: (err) => this.toast(`Could not read the market rate: ${apiErrorMessage(err)}`)
});
}
/** True when the rate entered is outside the day's low–high in the Daily Script Master. */
get purchaseRateOutOfRange(): boolean {
const m = this.purchaseMarketRate;
const f = this.purchaseForm;
const rate = this.isMutualFund ? f.nav : f.purchaseRate;
if (!m || rate == null) {
return false;
}
return (m.lowRate != null && rate < m.lowRate) || (m.highRate != null && rate > m.highRate);
}
/** Settlement Date = Deal Date + T+n days (greyed / auto in the existing CBS). */
get purchaseSettlementDate(): string {
const f = this.purchaseForm;
if (!f.dealDate) {
return '';
}
const n = parseInt(f.settlementType.replace('T+', ''), 10) || 0;
return addPeriod(f.dealDate, 0, 0, n);
}
/** Parties offered for a new deal — inactive parties are hidden. */
get activePartyOptions(): typeof PARTY_MASTER {
return this.partyOptions.filter((p) => p.status !== 'INACTIVE');
}
get purchaseInvestmentGlCode(): string {
return this.purchaseScript?.glCode || INVESTMENT_GL_BY_TYPE[this.purchaseForm.bondType];
}
glNameOf(glCode: string): string {
return INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ?? '';
}
/** The journal entry this purchase will post once the checker approves it. */
get purchaseAccountingPreview(): AccountingPreviewLine[] {
const book = this.purchaseBookValue;
const accrued = this.purchaseAccruedInterest;
const total = this.purchaseSettlementAmount;
const crGl = this.purchaseForm.crToAc;
if (!book || !crGl) {
return [];
}
const invGl = this.purchaseInvestmentGlCode;
const lines: AccountingPreviewLine[] = [
{ side: 'Dr', glCode: invGl, glName: this.glNameOf(invGl), amount: book }];
// Broken-period interest paid to the seller is an expense (RBI para 152, confirmed by Jitendra Sir).
if (accrued > 0) {
lines.push({
side: 'Dr',
glCode: BROKEN_PERIOD_INTEREST_GL.glCode,
glName: BROKEN_PERIOD_INTEREST_GL.glName,
amount: accrued
});
}
const f = this.purchaseForm;
for (const [
gl,
amount
] of [
[
STAMP_DUTY_GL,
f.stampDuty
],
[
BROKERAGE_GL,
f.brokerage
],
[
OTHER_CHARGES_GL,
f.otherCharges
]
] as const) {
if (amount && amount > 0) {
lines.push({ side: 'Dr', glCode: gl.glCode, glName: gl.glName, amount });
}
}
lines.push({ side: 'Cr', glCode: crGl, glName: this.glNameOf(crGl), amount: total });
return lines;
}
savePurchase(): void {
const f = this.purchaseForm;
const missing: string[] = [];
if (!f.scriptCode) missing.push('Script Code');
if (!f.partyCode) missing.push('Party Code');
if (!f.dealDate) missing.push('Deal Date');
if (!f.dealTime) missing.push('Deal Time');
if (this.isMutualFund) {
if (!f.considerAmt) missing.push('Consider Amt');
if (!f.nav) missing.push('NAV');
} else {
if (!f.faceValue) missing.push('Face Value');
if (!f.purchaseRate) missing.push('Purchase Rate');
if (!f.lastIntDt) missing.push('Last Interest Date');
}
if (!f.category) missing.push('Category');
if (!f.crToAc) missing.push('CR. To A/c');
if (missing.length) {
this.toast(`Please fill: ${missing.join(', ')}.`);
return;
}
const script = this.purchaseScript;
const party = this.partyOptions.find((p) => p.partyCode === f.partyCode);
const crGlAccountId = this.glIdByCode.get(f.crToAc);
if (!script?.id || !party?.id) {
this.toast('Script and Party must be saved in Investment Masters first.');
return;
}
if (!crGlAccountId) {
this.toast(`GL ${f.crToAc} was not found in the chart of accounts.`);
return;
}
const body = {
bondType: f.bondType,
scriptId: script.id,
partyId: party.id,
dealDate: f.dealDate,
dealTime: f.dealTime || '',
settlementType: f.settlementType,
...(this.isMutualFund
? { considerAmt: f.considerAmt, nav: f.nav }
: { faceValue: f.faceValue, purchaseRate: f.purchaseRate, lastIntDate: f.lastIntDt }),
stampDuty: f.stampDuty ?? null,
brokerage: f.brokerage ?? null,
otherCharges: f.otherCharges ?? null,
category: f.category,
crGlAccountId,
subType: f.subType || '',
otherDetails: this.purchaseOtherDetails(),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.investmentService.createPurchase(body).subscribe({
next: (res) => {
this.toast(
`Deal ${res.resourceIdentifier} saved — pending settlement. Settle it from Holdings on or after the settlement date.`
);
this.activeTab = 'holdings';
this.loadPurchases();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
/** The optional "More Details" fields, kept with the deal as they were entered. */
private purchaseOtherDetails(): Record<string, string | number> {
const f = this.purchaseForm;
const fields: Record<string, string | number | null> = {
tradeNo: f.tradeNo,
orderNo: f.orderNo,
brokerCode: f.brokerName,
recommAuthority: f.recommAuthority,
sanctionAuthority: f.sanctionAuthority,
meetDate: f.meetDt,
resoNo: f.resoNo,
dealType: f.dealType,
market: f.market,
markCblo: f.markCblo,
paymentMode: f.paymentMode,
deliveryMode: f.deliveryMode,
lien: f.lien,
lienAmount: f.lienAmount,
lienReason: f.lienReason,
bondRating: f.bondRating,
corpBond: f.corpBond,
sglWithBank: f.sglWithBank,
tbillGlAc: f.tbillGlAc,
intType: f.intType,
intDate: f.intDate,
folioNo: f.folioNo,
callOption: f.callOption,
descInv: f.descInv,
fromDist: f.fromDist,
toDist: f.toDist,
amortizeAmt: f.amortizeAmt,
registerAddress: f.registerAdd,
registerCity: f.regiCity,
registerPin: f.regiPin,
remarks: f.remarks,
description: f.description,
tradeAmt: f.tradeAmt,
holdingDays: f.holdingDays,
stampDutyAc: f.stampDutyAc
};
const out: Record<string, string | number> = {};
for (const [
k,
v
] of Object.entries(fields)) {
if (v !== null && v !== undefined && v !== '') {
out[k] = v;
}
}
return out;
}
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 certNo = this.retrieveCertNo.trim();
const h = this.holdings.find((x) => x.certNo === certNo);
if (!h) {
this.toast(`Cert No ${certNo || '(blank)'} 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 || '',
dealNo: h.dealNo ?? '',
partyCode: h.partyCode ?? '',
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,
category: h.category ?? ''
};
this.bondType = h.instrumentType as BondType;
this.toast(`Deal for Cert No ${h.certNo} retrieved.`);
}
/** Posts a pending deal to GL dated its settlement date; under maker-checker it waits in the Checker Inbox. */
settleDeal(h: InvestmentHolding): void {
if (!h.id) {
return;
}
this.investmentService.settlePurchase(h.id).subscribe({
next: (res) => {
this.toast(
res.rollbackTransaction
? `Settlement of deal ${h.dealNo} sent for approval (Checker Inbox).`
: `Deal ${h.dealNo} settled and posted to GL (transaction ${res.transactionId}).`
);
this.loadPurchases();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
dropPurchase(): void {
const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo);
if (!h?.id) {
this.toast('Retrieve a saved deal first, then Drop.');
return;
}
this.investmentService.deletePurchase(h.id).subscribe({
next: () => {
this.toast(`Deal ${h.dealNo} (Cert No ${h.certNo}) dropped.`);
this.loadPurchases();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
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 --------------------
/** Saved holdings for the Deal No dropdown, narrowed by Type and Script. */
get intDealOptions(): InvestmentHolding[] {
return this.holdings.filter(
(h) =>
h.id != null &&
h.status === 'ACTIVE' &&
(!this.intType || h.instrumentType === this.intType) &&
(!this.intScript || h.scriptCode === this.intScript)
);
}
get intScriptOptions(): typeof SCRIPT_MASTER {
return this.scriptOptions.filter((s) => !this.intType || s.instrumentType === this.intType);
}
onIntFilterChange(): void {
if (this.intScript && !this.intScriptOptions.some((s) => s.scriptCode === this.intScript)) {
this.intScript = '';
}
if (this.intPurchaseId != null && !this.intDealOptions.some((h) => h.id === this.intPurchaseId)) {
this.intPurchaseId = null;
}
this.intSchedule = null;
}
retrieveInterestSchedule(): void {
if (this.intPurchaseId == null) {
this.toast('Select a Deal No first.');
return;
}
this.intLoading = true;
this.investmentService.getInterestSchedule(this.intPurchaseId, this.intAsOnDate || undefined).subscribe({
next: (schedule) => {
this.intSchedule = schedule;
this.intBankGlCode = schedule.bankGlCode;
this.intLoading = false;
},
error: (err) => {
this.intSchedule = null;
this.intLoading = false;
this.toast(apiErrorMessage(err));
}
});
}
loadDueInterest(): void {
this.investmentService.getDueInterest().subscribe({
next: (tasks) => (this.intDueTasks = tasks),
error: () => (this.intDueTasks = [])
});
}
/** Opens a due task's holding in the schedule below. */
openDueTask(t: InvestmentInterestDueDto): void {
this.intType = '';
this.intScript = '';
this.intPurchaseId = t.purchaseId;
this.retrieveInterestSchedule();
}
/** Lines shown: up to 31 December of next year, plus anything already posted — unless the full schedule is asked for. */
get intVisibleRows(): InvestmentInterestRowDto[] {
const rows = this.intSchedule?.rows ?? [];
if (this.intShowFullSchedule) {
return rows;
}
const upTo = new Date(new Date().getFullYear() + 1, 11, 31);
return rows.filter((r) => r.posted || parseDDMMYYYY(r.intDate) <= upTo);
}
/** Posts one interest line with its JV voucher (passed as it is raised), then refreshes the schedule and tasks. */
generateVoucher(r: InvestmentInterestRowDto): void {
const sc = this.intSchedule;
if (!sc || this.intPosting) {
return;
}
const bankGlAccountId =
r.type === 'COUPON' ? (this.glIdByCode.get(this.intBankGlCode) ?? sc.bankGlAccountId) : undefined;
const body = {
lineType: r.type,
intDate: r.intDate,
...(bankGlAccountId ? { bankGlAccountId } : {}),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.intPosting = true;
this.investmentService.postInterest(sc.purchaseId, body).subscribe({
next: (res) => {
this.intPosting = false;
this.toast(
`${r.type === 'COUPON' ? 'Coupon' : 'Provision'} of ${r.intDate} posted — voucher ${res.resourceIdentifier ?? ''}.`
);
this.retrieveInterestSchedule();
this.loadDueInterest();
},
error: (err) => {
this.intPosting = false;
this.toast(apiErrorMessage(err));
}
});
}
/** Total of the lines falling due up to the as-on date. */
get intDueTotal(): number {
return (this.intSchedule?.rows ?? []).filter((r) => r.due).reduce((sum, r) => sum + r.amount, 0);
}
// -------------------- 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);
}
}