1621 lines
51 KiB
TypeScript
1621 lines
51 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, 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);
|
||
}
|
||
}
|