feat(investment): wire Bank/Party, Script Master and Purchase Entry to the backend

- Bank / Party Master and Script Master follow the bank's existing CBS
  screens (address, bank type, GL A/c, RBI code, limit, LEI, counterparty
  bank details; SLR type, auto script code, ISIN, interest rate, issue /
  maturity date, auto period, company details) and save to
  /investment/parties and /investment/scripts. GL dropdowns list enabled
  ASSET detail accounts from the chart of accounts.
- Purchase Entry simplified to the mandatory fields; script details,
  settlement date, last interest date, units, book value, premium/discount,
  accrued interest (30/360 up to the deal date), settlement amount and YTM
  are filled automatically; optional fields sit under "More Details";
  an Accounting Preview shows the GL entry. Save / Retrieve / Drop use
  /investment/purchases and the Holdings register lists saved purchases.
- New InvestmentService for the investment API calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 12:38:10 +05:30
co-authored by Claude Opus 5.5
parent 5ab4e9eaf0
commit a4e0a22205
6 changed files with 1863 additions and 449 deletions
@@ -6,16 +6,11 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
MUTUAL_FUND_HOLDINGS,
ALL_TXNS,
SCRIPT_MASTER,
PARTY_MASTER,
SUBTYPE_MASTER,
@@ -33,6 +28,14 @@ import {
AUTHORITY_OPTIONS,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
INVESTMENT_GL_BY_TYPE,
INVESTMENT_GL_LOOKUP,
INTEREST_RECEIVABLE_GL,
SETTLEMENT_BANK_GL_OPTIONS,
periodBetween,
parseDDMMYYYY,
days360,
lastHalfYearlyCouponDate,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
@@ -49,6 +52,7 @@ import {
todayDDMMYYYY
} from '../investment-data';
import { exportWorkbook } from '../investment-export';
import { InvestmentPurchaseDto, InvestmentService, apiErrorMessage, toHolding } from '../investment.service';
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
type BondTab =
@@ -131,6 +135,13 @@ interface BondPurchaseForm {
stampDutyAc: string;
}
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';
@@ -182,23 +193,67 @@ interface BondSellForm {
DateDdmmyyyyPickerComponent
]
})
export class BondInvestmentComponent {
export class BondInvestmentComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
/** Script and party dropdowns offer the masters saved in the backend. */
ngOnInit(): void {
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();
}
/** 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)}`)
});
}
private purchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn {
return {
certNo: d.certNo,
instrumentType: d.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${d.dealNo}`,
txnDate: d.dealDate,
amount: d.settlementAmount,
particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]`,
glCode: d.investmentGlCode,
glName: d.investmentGlName
};
}
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();
holdings: InvestmentHolding[] = [
...GOVT_SECURITY_HOLDINGS,
...NON_SLR_HOLDINGS,
...BOND_HOLDINGS,
...MUTUAL_FUND_HOLDINGS
];
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => ([
'GOVT_SECURITY',
'NON_SLR',
'BOND',
'MUTUAL_FUND'
] as InstrumentType[]).includes(t.instrumentType));
/** Loaded from the backend (saved purchases) in ngOnInit. */
holdings: InvestmentHolding[] = [];
txns: InvestmentTxn[] = [];
// Master-driven dropdown sources
readonly scriptOptions = SCRIPT_MASTER;
@@ -224,6 +279,7 @@ export class BondInvestmentComponent {
purchaseForm: BondPurchaseForm = this.blankPurchaseForm();
sellForm: BondSellForm = this.blankSellForm();
readonly settlementBankOptions = SETTLEMENT_BANK_GL_OPTIONS;
// Redemption Entry
redeemCertNo = '';
@@ -305,20 +361,37 @@ export class BondInvestmentComponent {
return INSTRUMENT_LABELS[type];
}
/** Preview of the deal number the backend will assign: FY prefix (e.g. 202627) + running number. */
private nextDealNo(): string {
return `2026${String(27000039 + this.holdings.length).padStart(9, '0')}`;
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 = '';
return {
bondType: type,
dealNo: this.nextDealNo(),
dealDate: this.asOnDate,
dealTime: '',
dealTime: new Date().toTimeString().slice(0, 8),
isin: '',
scriptCode: '',
certNo: '',
certNo: this.nextCertNo(),
settlementType: 'T+0',
settlementDt: this.asOnDate,
faceValue: null,
@@ -326,7 +399,7 @@ export class BondInvestmentComponent {
partyCode: '',
subType: type === 'GOVT_SECURITY' ? 'SLR' : type === 'NON_SLR' ? 'NON SLR' : 'CORPORATE BOND',
tbillGlAc: '',
crToAc: '310070',
crToAc: SETTLEMENT_BANK_GL_OPTIONS[0]?.glCode ?? '',
premiumAmt: null,
brokerage: null,
brokerName: '',
@@ -463,13 +536,41 @@ export class BondInvestmentComponent {
}
/** Accrued Interest at purchase — interest earned by the seller since the last coupon date. */
/** Interest owed to the seller from the last coupon date up to the deal date, on a 30/360 basis —
* interest is counted to the event date, not the date the money moves (Jitendra Sir). */
get purchaseAccruedInterest(): number {
const f = this.purchaseForm;
if (!f.intRate || !f.lastIntDt || !f.dealDate) {
return 0;
}
const yrs = Math.max(0, yearsBetween(f.lastIntDt, f.dealDate));
return Math.round(this.purchaseBaseAmount * (f.intRate / 100) * yrs);
return Math.round((this.purchaseBaseAmount * (f.intRate / 100) * days360(f.lastIntDt, f.dealDate)) / 360);
}
get purchaseAccruedDays(): number {
const f = this.purchaseForm;
return f.lastIntDt && f.dealDate ? days360(f.lastIntDt, f.dealDate) : 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();
}
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 plus accrued interest payable to the seller. */
@@ -504,69 +605,196 @@ export class BondInvestmentComponent {
});
}
/** Fills ISIN + interest rate when a script is picked, mirroring the legacy form's lookup. */
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.scriptOptions.find((x) => x.scriptCode === this.purchaseForm.scriptCode);
if (s) {
this.purchaseForm.isin = s.isin;
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();
}
/** 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 }];
if (accrued > 0) {
lines.push({
side: 'Dr',
glCode: INTEREST_RECEIVABLE_GL.glCode,
glName: INTEREST_RECEIVABLE_GL.glName,
amount: accrued
});
}
lines.push({ side: 'Cr', glCode: crGl, glName: this.glNameOf(crGl), amount: total });
return lines;
}
savePurchase(): void {
const f = this.purchaseForm;
if (!f.certNo || !this.purchaseBaseAmount || !f.partyCode) {
this.toast('Please fill Cert No, Face Value / Consider Amt and Party Code.');
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 (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 holding: InvestmentHolding = {
certNo: f.certNo,
instrumentType: f.bondType,
scriptCode: f.scriptCode || f.isin || f.productName,
scriptName: this.isMutualFund
? f.productName
: this.scriptOptions.find((s) => s.scriptCode === f.scriptCode)?.scriptName || f.scriptCode || f.isin,
party: f.partyCode,
subType: f.subType,
faceValue: this.purchaseBaseAmount,
purchaseRate: this.isMutualFund ? f.nav || 0 : f.purchaseRate || 100,
units: this.purchaseUnits,
purchaseDate: f.issueDate,
maturityDate: this.purchaseMaturityDate || f.issueDate,
interestRate: f.intRate || 0,
interestFreq: this.isMutualFund ? 'CUMULATIVE' : f.intType,
glCode:
'31' +
(f.bondType === 'GOVT_SECURITY'
? '0020'
: f.bondType === 'NON_SLR'
? '0030'
: f.bondType === 'BOND'
? '0040'
: '0050'),
glName: `INVESTMENT IN ${INSTRUMENT_LABELS[f.bondType].toUpperCase()}`,
crToAc: f.crToAc,
brokerCode: f.brokerName,
status: 'ACTIVE'
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, stampDuty: f.stampDuty ?? null }
: { faceValue: f.faceValue, purchaseRate: f.purchaseRate, lastIntDate: f.lastIntDt }),
brokerage: f.brokerage ?? null,
category: f.category,
crGlAccountId,
subType: f.subType || '',
otherDetails: this.purchaseOtherDetails(),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.holdings = [
holding,
...this.holdings
];
this.txns = [
{
certNo: holding.certNo,
instrumentType: f.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${f.dealNo}`,
txnDate: f.dealDate,
amount: this.purchaseSettlementAmount,
particulars: `Purchase Deal ${f.dealNo} - ${holding.scriptName} - ${holding.party}${f.category ? ' [' + f.category + ']' : ''}`
this.investmentService.createPurchase(body).subscribe({
next: (res) => {
this.toast(`Deal ${res.resourceIdentifier} saved. GL posting will follow once accounting is approved.`);
this.activeTab = 'holdings';
this.loadPurchases();
},
...this.txns
];
this.toast(`${INSTRUMENT_LABELS[f.bondType]} Certificate ${holding.certNo} saved successfully.`);
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
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 {
@@ -576,9 +804,10 @@ export class BondInvestmentComponent {
/** Legacy "Retrieve" button — loads an existing deal back into the form by Cert No. */
retrievePurchase(): void {
const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo.trim());
const certNo = this.retrieveCertNo.trim();
const h = this.holdings.find((x) => x.certNo === certNo);
if (!h) {
this.toast(`Cert No ${this.purchaseForm.certNo} not found.`);
this.toast(`Cert No ${certNo || '(blank)'} not found.`);
return;
}
this.purchaseForm = {
@@ -587,7 +816,8 @@ export class BondInvestmentComponent {
certNo: h.certNo,
scriptCode: h.scriptCode,
isin: this.scriptOptions.find((s) => s.scriptCode === h.scriptCode)?.isin || '',
partyCode: h.party,
dealNo: h.dealNo ?? '',
partyCode: h.partyCode ?? '',
subType: h.subType,
faceValue: h.faceValue,
considerAmt: h.faceValue,
@@ -599,22 +829,26 @@ export class BondInvestmentComponent {
intType: h.interestFreq,
brokerName: h.brokerCode,
crToAc: h.crToAc,
productName: h.scriptName
productName: h.scriptName,
category: h.category ?? ''
};
this.bondType = h.instrumentType as BondType;
this.toast(`Deal for Cert No ${h.certNo} retrieved.`);
}
dropPurchase(): void {
const certNo = this.purchaseForm.certNo;
if (!this.holdings.some((h) => h.certNo === certNo)) {
this.toast('Nothing to drop — this deal is not yet saved.');
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.holdings = this.holdings.filter((h) => h.certNo !== certNo);
this.txns = this.txns.filter((t) => t.certNo !== certNo);
this.purchaseForm = this.blankPurchaseForm();
this.toast(`Cert No ${certNo} dropped.`);
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 {