From a4e0a2220518a0ef0f1cfca1fb1b9d42237a9550 Mon Sep 17 00:00:00 2001 From: anshul Date: Thu, 8 Oct 2026 12:38:10 +0530 Subject: [PATCH] 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 --- .../bond-investment.component.html | 543 ++++++++++-------- .../bond-investment.component.ts | 412 ++++++++++--- src/app/investment/investment-data.ts | 450 ++++++++++++++- .../investment-masters.component.html | 233 ++++++-- .../investment-masters.component.ts | 374 ++++++++++-- src/app/investment/investment.service.ts | 300 ++++++++++ 6 files changed, 1863 insertions(+), 449 deletions(-) create mode 100644 src/app/investment/investment.service.ts diff --git a/src/app/investment/bond-investment/bond-investment.component.html b/src/app/investment/bond-investment/bond-investment.component.html index de42824e6..bfcc033c6 100644 --- a/src/app/investment/bond-investment/bond-investment.component.html +++ b/src/app/investment/bond-investment/bond-investment.component.html @@ -176,8 +176,9 @@ @if (activeTab === 'purchase') {
+
- - - - - + + - - - - @if (isMutualFund) { - - - - - } @else { - - - } - - - - - - - - - - - - - - - - - - - - - + } + +
+ + + +
+ + + + + + @if (!isMutualFund) { + + + + } + + + @if (!isMutualFund) { + + + + } + +
+ + + + @if (purchaseAccountingPreview.length) { + + + + + + + + + + + @for (l of purchaseAccountingPreview; track l.side + l.glCode) { + + + + + + + } + +
Dr / CrGL CodeGL NameAmount
{{ l.side }}{{ l.glCode }}{{ l.glName || '—' }}₹{{ l.amount | number: '1.2-2' }}
+ } @else { +

Select a script, enter the amount and CR. To A/c to see the entry.

+ } + + +
+ +
+ @if (showMoreDetails) { + +
+ + @if (isMutualFund) { + + + + + } @else { + + + + + } + + - - + + + + + + + + @if (purchaseForm.lien === 'YES') { + + + } + + + + + + + + + + + - - - - } - - @if (purchaseForm.lien === 'YES') { - - - } - - - - - -
+
+ } +
+ diff --git a/src/app/investment/bond-investment/bond-investment.component.ts b/src/app/investment/bond-investment/bond-investment.component.ts index e947df3f3..414a45801 100644 --- a/src/app/investment/bond-investment/bond-investment.component.ts +++ b/src/app/investment/bond-investment/bond-investment.component.ts @@ -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(); + + /** 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 { + const f = this.purchaseForm; + const fields: Record = { + 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 = {}; + 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 { diff --git a/src/app/investment/investment-data.ts b/src/app/investment/investment-data.ts index 0ea88ce1a..d570dd23e 100644 --- a/src/app/investment/investment-data.ts +++ b/src/app/investment/investment-data.ts @@ -123,6 +123,16 @@ export interface InvestmentHolding { * never re-counts days up to this date, so re-running a Calculation & Post for an overlapping * or repeated period always yields 0 instead of double-posting the same interest. */ interestPostedTillDate?: string; + /** RBI investment category chosen at purchase — HTM / AFS / HFT. */ + category?: string; + /** SGL / CSGL account the security is held in (custody, not an accounting GL). */ + sglAccountNo?: string; + /** Backend id / deal number / party code once the purchase is saved (absent on sample rows). */ + id?: number; + dealNo?: string; + partyCode?: string; + bookValue?: number; + settlementAmount?: number; } export interface InvestmentTxn { @@ -248,6 +258,51 @@ export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, to return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25)); } +/** Days between two DD-MM-YYYY dates on a 30/360 basis (every month 30 days), as the bank's existing + * CBS counts them — e.g. 10-08 → 30-09 = 50 days, 10-08 → 07-10 = 57 days. */ +export function days360(fromStr: string, toStr: string): number { + const a = parseDDMMYYYY(fromStr); + const b = parseDDMMYYYY(toStr); + if (isNaN(a.getTime()) || isNaN(b.getTime()) || b <= a) { + return 0; + } + const d1 = Math.min(a.getDate(), 30); + const d2 = Math.min(b.getDate(), 30); + return (b.getFullYear() - a.getFullYear()) * 360 + (b.getMonth() - a.getMonth()) * 30 + (d2 - d1); +} + +/** Most recent half-yearly coupon date on or before `onStr`, derived from the maturity date (coupons + * fall on the maturity day/month and six months either side of it). */ +export function lastHalfYearlyCouponDate(maturityStr: string, onStr: string): string { + const mat = parseDDMMYYYY(maturityStr); + const on = parseDDMMYYYY(onStr); + if (isNaN(mat.getTime()) || isNaN(on.getTime())) { + return ''; + } + const day = mat.getDate(); + const couponOn = (year: number, month: number): Date => { + const lastDay = new Date(year, month + 1, 0).getDate(); + return new Date(year, month, Math.min(day, lastDay)); + }; + const months = [ + mat.getMonth() % 6, + (mat.getMonth() % 6) + 6 + ]; + let best: Date | undefined; + for (const year of [ + on.getFullYear() - 1, + on.getFullYear() + ]) { + for (const m of months) { + const c = couponOn(year, m); + if (c <= on && (!best || c > best)) { + best = c; + } + } + } + return best ? formatDDMMYYYY(best) : ''; +} + export function formatDDMMYYYY(d: Date): string { return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; } @@ -1404,99 +1459,319 @@ export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [ { glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' } ]; +/** Investment GLs a script / party can be mapped to. PROPOSED codes awaiting approval — the + * backend will take these from the chart of accounts, never hard-coded. */ +export const INVESTMENT_GL_OPTIONS: InvestmentGlOption[] = [ + { glCode: '100701', glName: 'INVESTMENT IN CENTRAL GOVT SECURITIES' }, + { glCode: '100702', glName: 'INVESTMENT IN STATE GOVT SECURITIES' }, + { glCode: '100703', glName: 'INVESTMENT IN BONDS / DEBENTURES' }, + { glCode: '100704', glName: 'FIXED DEPOSITS WITH OTHER BANKS' }, + { glCode: '100705', glName: 'INVESTMENT IN MUTUAL FUNDS' }, + { glCode: '100706', glName: 'INVESTMENT IN SHARES' } +]; + +/** Fallback investment GL when a script has none mapped. */ +export const INVESTMENT_GL_BY_TYPE: Record = { + GOVT_SECURITY: '100701', + NON_SLR: '100703', + BOND: '100703', + FD: '100704', + MUTUAL_FUND: '100705', + SHARE: '100706' +}; + +/** Interest receivable (the senior's "Provision A/c") — debited for interest bought from the seller. */ +export const INTEREST_RECEIVABLE_GL: InvestmentGlOption = { + glCode: '100603', + glName: 'INTEREST RECEIVABLE ON INVESTMENTS (PROVISION)' +}; + +/** Bank accounts a purchase can be paid from — these GLs already exist in the chart of accounts. */ +export const SETTLEMENT_BANK_GL_OPTIONS: InvestmentGlOption[] = [ + { glCode: '100201', glName: 'IDBI BANK' }, + { glCode: '100202', glName: 'YES BANK' } +]; + +/** Every GL the Investment screens can show by name. */ +export const INVESTMENT_GL_LOOKUP: InvestmentGlOption[] = [ + ...INVESTMENT_GL_OPTIONS, + INTEREST_RECEIVABLE_GL, + ...SETTLEMENT_BANK_GL_OPTIONS +]; + +/** SLR classification on the Script Master, as in the bank's existing CBS ("GOI" = Central Govt). */ +export type SlrType = 'GOI' | 'STATE_GOVT' | 'TREASURY' | 'NON_SLR'; + +export const SLR_TYPE_OPTIONS: Array<{ value: SlrType; label: string }> = [ + { value: 'GOI', label: 'GOI' }, + { value: 'STATE_GOVT', label: 'STATE GOVT' }, + { value: 'TREASURY', label: 'TREASURY' }, + { value: 'NON_SLR', label: 'NON SLR' } +]; + export interface ScriptMaster { + /** Backend id once the script is saved (absent on the sample rows). */ + id?: number; scriptCode: string; scriptName: string; instrumentType: InstrumentType; isin: string; faceValuePerUnit: number; + // Fields below follow the bank's existing CBS Script Master; a purchase picks them up instead + // of the dealer re-typing them on every deal. + slrType?: SlrType; + /** Investment GL this script is booked to. */ + glAccountId?: number; + glCode?: string; + glName?: string; + active?: boolean; + /** Interest (coupon) rate % per annum. */ + couponRate?: number; + issueDate?: string; + maturityDate?: string; + // Issuer details — used for Bond / Mutual Fund scripts. + companyName?: string; + address1?: string; + address2?: string; + address3?: string; + city?: string; + pincode?: string; + phone?: string; + fax?: string; + corpCode?: string; +} + +/** Period between two DD-MM-YYYY dates as years / months / days, as shown on the Script Master. */ +export function periodBetween(fromStr?: string, toStr?: string): { years: number; months: number; days: number } { + const none = { years: 0, months: 0, days: 0 }; + if (!fromStr || !toStr) { + return none; + } + const from = parseDDMMYYYY(fromStr); + const to = parseDDMMYYYY(toStr); + if (isNaN(from.getTime()) || isNaN(to.getTime()) || to < from) { + return none; + } + let years = to.getFullYear() - from.getFullYear(); + let months = to.getMonth() - from.getMonth(); + let days = to.getDate() - from.getDate(); + if (days < 0) { + months -= 1; + days += new Date(to.getFullYear(), to.getMonth(), 0).getDate(); + } + if (months < 0) { + years -= 1; + months += 12; + } + return { years, months, days }; } export const SCRIPT_MASTER: ScriptMaster[] = [ - { scriptCode: 'FD-CUM', scriptName: 'Cumulative Fixed Deposit', instrumentType: 'FD', isin: '', faceValuePerUnit: 1 }, + { + scriptCode: 'FD-CUM', + scriptName: 'Cumulative Fixed Deposit', + instrumentType: 'FD', + isin: '', + faceValuePerUnit: 1, + slrType: 'NON_SLR', + glCode: '100704' + }, { scriptCode: 'FD-MIP', scriptName: 'Monthly Interest Payout FD', instrumentType: 'FD', isin: '', - faceValuePerUnit: 1 + faceValuePerUnit: 1, + slrType: 'NON_SLR', + glCode: '100704' }, { scriptCode: 'IN0020230012', scriptName: '7.26% GOI 2033', instrumentType: 'GOVT_SECURITY', isin: 'IN0020230012', - faceValuePerUnit: 100 + faceValuePerUnit: 100, + slrType: 'GOI', + glCode: '100701', + couponRate: 7.26, + issueDate: '15-01-2023', + maturityDate: '15-01-2033' }, { scriptCode: 'IN0020220089', scriptName: '7.10% GOI 2029', instrumentType: 'GOVT_SECURITY', isin: 'IN0020220089', - faceValuePerUnit: 100 + faceValuePerUnit: 100, + slrType: 'GOI', + glCode: '100701', + couponRate: 7.1, + issueDate: '10-03-2022', + maturityDate: '10-03-2029' + }, + { + scriptCode: 'IN0020210056', + scriptName: '6.79% GOI 2027', + instrumentType: 'GOVT_SECURITY', + isin: 'IN0020210056', + faceValuePerUnit: 100, + slrType: 'GOI', + glCode: '100701', + couponRate: 6.79, + issueDate: '05-06-2021', + maturityDate: '05-06-2027' + }, + { + scriptCode: 'IN0020240034', + scriptName: '7.18% GOI 2037', + instrumentType: 'GOVT_SECURITY', + isin: 'IN0020240034', + faceValuePerUnit: 100, + slrType: 'GOI', + glCode: '100701', + couponRate: 7.18, + issueDate: '22-04-2024', + maturityDate: '22-04-2037' + }, + { + scriptCode: 'MH-SDL-2031', + scriptName: '8.05% Maharashtra SDL 2031', + instrumentType: 'GOVT_SECURITY', + isin: '', + faceValuePerUnit: 100, + slrType: 'STATE_GOVT', + glCode: '100702', + couponRate: 8.05, + issueDate: '15-02-2021', + maturityDate: '15-02-2031' + }, + { + scriptCode: 'GJ-SDL-2026', + scriptName: '7.85% Gujarat SDL 2026', + instrumentType: 'GOVT_SECURITY', + isin: '', + faceValuePerUnit: 100, + slrType: 'STATE_GOVT', + glCode: '100702', + couponRate: 7.85, + issueDate: '18-09-2016', + maturityDate: '18-09-2026' }, { scriptCode: 'TBILL-364D', scriptName: '364 Days Treasury Bill', instrumentType: 'GOVT_SECURITY', isin: '', - faceValuePerUnit: 100 + faceValuePerUnit: 100, + slrType: 'TREASURY', + glCode: '100701', + couponRate: 0, + issueDate: '02-07-2026', + maturityDate: '02-07-2027' + }, + { + scriptCode: 'TBILL-91D', + scriptName: '91 Days Treasury Bill', + instrumentType: 'GOVT_SECURITY', + isin: '', + faceValuePerUnit: 100, + slrType: 'TREASURY', + glCode: '100701', + couponRate: 0, + issueDate: '15-05-2026', + maturityDate: '13-08-2026' }, { scriptCode: 'REC-7.5-2031', scriptName: 'REC Ltd Bond 7.50% 2031', instrumentType: 'NON_SLR', isin: 'INE020B08JN0', - faceValuePerUnit: 100 + faceValuePerUnit: 100, + slrType: 'NON_SLR', + glCode: '100703', + couponRate: 7.5, + issueDate: '11-08-2021', + maturityDate: '11-08-2031', + companyName: 'REC Limited', + city: 'New Delhi' }, { scriptCode: 'NABARD-7.35-2030', scriptName: 'NABARD Bond 7.35% 2030', instrumentType: 'NON_SLR', isin: 'INE261F09EU1', - faceValuePerUnit: 100 + faceValuePerUnit: 100, + slrType: 'NON_SLR', + glCode: '100703', + couponRate: 7.35, + issueDate: '20-09-2020', + maturityDate: '20-09-2030', + companyName: 'NABARD', + city: 'Mumbai' }, { scriptCode: 'LTF-8.1-2028', scriptName: 'L&T Finance NCD 8.10% 2028', instrumentType: 'BOND', isin: 'INE027E07BR5', - faceValuePerUnit: 100 + faceValuePerUnit: 100, + slrType: 'NON_SLR', + glCode: '100703', + couponRate: 8.1, + companyName: 'L&T Finance Ltd', + city: 'Mumbai' }, { scriptCode: 'BAF-7.95-2027', scriptName: 'Bajaj Finance NCD 7.95% 2027', instrumentType: 'BOND', isin: 'INE296A07SP0', - faceValuePerUnit: 100 + faceValuePerUnit: 100, + slrType: 'NON_SLR', + glCode: '100703', + couponRate: 7.95, + companyName: 'Bajaj Finance Ltd', + city: 'Pune' }, { scriptCode: 'SBI-OVN', scriptName: 'SBI Overnight Fund', instrumentType: 'MUTUAL_FUND', isin: 'INF200KA1Q40', - faceValuePerUnit: 1000 + faceValuePerUnit: 1000, + slrType: 'NON_SLR', + glCode: '100705', + companyName: 'SBI Mutual Fund', + city: 'Mumbai' }, { scriptCode: 'HDFC-LIQ', scriptName: 'HDFC Liquid Fund', instrumentType: 'MUTUAL_FUND', isin: 'INF179KA1WR9', - faceValuePerUnit: 1000 + faceValuePerUnit: 1000, + slrType: 'NON_SLR', + glCode: '100705', + companyName: 'HDFC Mutual Fund', + city: 'Mumbai' }, { scriptCode: 'SBIN', scriptName: 'State Bank of India', instrumentType: 'SHARE', isin: 'INE062A01020', - faceValuePerUnit: 1 + faceValuePerUnit: 1, + slrType: 'NON_SLR', + glCode: '100706' }, { scriptCode: 'HDFCBANK', scriptName: 'HDFC Bank Ltd', instrumentType: 'SHARE', isin: 'INE040A01034', - faceValuePerUnit: 1 + faceValuePerUnit: 1, + slrType: 'NON_SLR', + glCode: '100706' } ]; @@ -1561,23 +1836,150 @@ export const BROKER_MASTER: BrokerMaster[] = [ ]; export interface PartyMaster { + /** Backend id once the party is saved (absent on the sample rows). */ + id?: number; partyCode: string; partyName: string; partyType: 'BANK' | 'CORPORATE' | 'GOVT' | 'AMC' | 'EXCHANGE' | 'COOP_SOCIETY'; - panNo: string; + panNo?: string; + // Fields below follow the bank's existing CBS Bank / Party Master. + bondType?: InstrumentType; + address1?: string; + address2?: string; + address3?: string; + city?: string; + pincode?: string; + phone?: string; + fax?: string; + bankType?: string; + /** GL this party is mapped to. */ + glAccountId?: number; + glCode?: string; + glName?: string; + rbiCode?: string; + /** Exposure limit for this party (₹). */ + limit?: number | null; + leiNo?: string; + // Counterparty's bank details + cpBankName?: string; + cpBranchName?: string; + cpAccountNo?: string; + cpIfsc?: string; + /** Absent means ACTIVE. An INACTIVE party is not offered for new deals. */ + status?: 'ACTIVE' | 'INACTIVE'; } +/** Sample Bank Type options until the bank confirms its list. */ +export const BANK_TYPE_OPTIONS = [ + 'SCHEDULED COMMERCIAL BANK', + 'STATE CO-OPERATIVE BANK', + 'DISTRICT CENTRAL CO-OPERATIVE BANK', + 'URBAN CO-OPERATIVE BANK', + 'RBI / GOVERNMENT', + 'OTHER' +]; + export const PARTY_MASTER: PartyMaster[] = [ - { partyCode: 'PTY-001', partyName: 'Jila Sahakari Kendriya Bank', partyType: 'BANK', panNo: 'AABCJ1234K' }, - { partyCode: 'PTY-002', partyName: 'State Cooperative Bank Ltd', partyType: 'BANK', panNo: 'AABCS5678L' }, - { partyCode: 'PTY-003', partyName: 'RBI - SGL', partyType: 'GOVT', panNo: 'AAACR4849R' }, - { partyCode: 'PTY-004', partyName: 'REC Limited', partyType: 'CORPORATE', panNo: 'AAACR0641G' }, - { partyCode: 'PTY-005', partyName: 'NABARD', partyType: 'CORPORATE', panNo: 'AAACN0272B' }, - { partyCode: 'PTY-006', partyName: 'L&T Finance Ltd', partyType: 'CORPORATE', panNo: 'AAACL0140P' }, - { partyCode: 'PTY-007', partyName: 'SBI Mutual Fund', partyType: 'AMC', panNo: 'AAATS4325L' }, - { partyCode: 'PTY-008', partyName: 'HDFC Mutual Fund', partyType: 'AMC', panNo: 'AAATH1234M' }, - { partyCode: 'PTY-009', partyName: 'NSE/BSE', partyType: 'EXCHANGE', panNo: 'AAACN0028G' }, - { partyCode: 'PTY-010', partyName: 'District Cooperative Bank', partyType: 'COOP_SOCIETY', panNo: 'AABCD9988K' } + { + partyCode: 'PTY-001', + partyName: 'Jila Sahakari Kendriya Bank', + partyType: 'BANK', + bondType: 'FD', + glCode: '100704', + city: 'Vidisha', + bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK', + status: 'ACTIVE' + }, + { + partyCode: 'PTY-002', + partyName: 'State Cooperative Bank Ltd', + partyType: 'BANK', + bondType: 'FD', + glCode: '100704', + city: 'Bhopal', + bankType: 'STATE CO-OPERATIVE BANK', + status: 'ACTIVE' + }, + { + partyCode: 'PTY-003', + partyName: 'RBI - SGL', + partyType: 'GOVT', + bondType: 'GOVT_SECURITY', + glCode: '100701', + city: 'Mumbai', + bankType: 'RBI / GOVERNMENT', + status: 'ACTIVE' + }, + { + partyCode: 'PTY-004', + partyName: 'REC Limited', + partyType: 'CORPORATE', + bondType: 'NON_SLR', + glCode: '100703', + city: 'New Delhi', + bankType: 'OTHER', + status: 'ACTIVE' + }, + { + partyCode: 'PTY-005', + partyName: 'NABARD', + partyType: 'CORPORATE', + bondType: 'NON_SLR', + glCode: '100703', + city: 'Mumbai', + bankType: 'OTHER', + status: 'ACTIVE' + }, + { + partyCode: 'PTY-006', + partyName: 'L&T Finance Ltd', + partyType: 'CORPORATE', + bondType: 'BOND', + glCode: '100703', + city: 'Mumbai', + bankType: 'OTHER', + status: 'ACTIVE' + }, + { + partyCode: 'PTY-007', + partyName: 'SBI Mutual Fund', + partyType: 'AMC', + bondType: 'MUTUAL_FUND', + glCode: '100705', + city: 'Mumbai', + bankType: 'OTHER', + status: 'ACTIVE' + }, + { + partyCode: 'PTY-008', + partyName: 'HDFC Mutual Fund', + partyType: 'AMC', + bondType: 'MUTUAL_FUND', + glCode: '100705', + city: 'Mumbai', + bankType: 'OTHER', + status: 'ACTIVE' + }, + { + partyCode: 'PTY-009', + partyName: 'NSE/BSE', + partyType: 'EXCHANGE', + bondType: 'SHARE', + glCode: '100706', + city: 'Mumbai', + bankType: 'OTHER', + status: 'ACTIVE' + }, + { + partyCode: 'PTY-010', + partyName: 'District Cooperative Bank', + partyType: 'COOP_SOCIETY', + bondType: 'SHARE', + glCode: '100706', + city: 'Vidisha', + bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK', + status: 'ACTIVE' + } ]; export interface SubTypeMaster { diff --git a/src/app/investment/investment-masters/investment-masters.component.html b/src/app/investment/investment-masters/investment-masters.component.html index ebc0dbf2a..9670bd9fa 100644 --- a/src/app/investment/investment-masters/investment-masters.component.html +++ b/src/app/investment/investment-masters/investment-masters.component.html @@ -106,38 +106,112 @@
- + @if (activeTab === 'party') {
- +
- - + + + + + + + + + + + + + + + +
+ +
+ + + +
- + + + + + +
+ - - + + + + + + @@ -146,9 +220,14 @@ + - - + + + + + + } @@ -156,42 +235,118 @@ } - + @if (activeTab === 'script') {
- +
- - - + + + + + + + + + + +
+ +
+ + + + + + + + +
- + + + + +
Party CodeBond Type Party NameTypePAN NoCityBank TypeGL A/cLimitCounterparty Bank / IFSCStatus
{{ p.partyCode }} {{ p.bondType || '—' }} {{ p.partyName }}{{ p.partyType }}{{ p.panNo }}{{ p.city || '—' }}{{ p.bankType || '—' }}{{ p.glCode ? p.glCode + ' — ' + glName(p.glCode) : '—' }}{{ p.limit != null ? '₹' + (p.limit | number: '1.0-0') : '—' }}{{ p.cpBankName ? p.cpBankName + (p.cpIfsc ? ' / ' + p.cpIfsc : '') : '—' }}{{ p.status ?? 'ACTIVE' }}
- - + + + + - + + + @@ -200,10 +355,14 @@ - + + + - + + + } diff --git a/src/app/investment/investment-masters/investment-masters.component.ts b/src/app/investment/investment-masters/investment-masters.component.ts index 31d8d1116..967892e37 100644 --- a/src/app/investment/investment-masters/investment-masters.component.ts +++ b/src/app/investment/investment-masters/investment-masters.component.ts @@ -6,14 +6,12 @@ * 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 { - SCRIPT_MASTER, BROKER_MASTER, - PARTY_MASTER, SUBTYPE_MASTER, CONTROL_PARAMETERS, MUTUAL_FUND_PRODUCTS, @@ -31,9 +29,14 @@ import { ScriptMarketDetail, DailyScriptRate, LienReason, - InstrumentType + InstrumentType, + BANK_TYPE_OPTIONS, + SLR_TYPE_OPTIONS, + INVESTMENT_GL_LOOKUP, + periodBetween } from '../investment-data'; import { exportWorkbook, ExportSheetSpec } from '../investment-export'; +import { InvestmentGlAccount, InvestmentService, apiErrorMessage } from '../investment.service'; type MasterTab = | 'party' @@ -65,12 +68,12 @@ type MasterTab = DateDdmmyyyyPickerComponent ] }) -export class InvestmentMastersComponent { +export class InvestmentMastersComponent implements OnInit { activeTab: MasterTab = 'party'; - scripts: ScriptMaster[] = [...SCRIPT_MASTER]; + scripts: ScriptMaster[] = []; brokers: BrokerMaster[] = [...BROKER_MASTER]; - parties: PartyMaster[] = [...PARTY_MASTER]; + parties: PartyMaster[] = []; subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER]; controlParams: ControlParameter[] = [...CONTROL_PARAMETERS]; mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS]; @@ -106,8 +109,19 @@ export class InvestmentMastersComponent { toastVisible = false; private toastTimer: ReturnType | undefined; - newParty: Partial = {}; - newScript: Partial = {}; + // Bank / Party Master and Script Master follow the bank's existing CBS screens (one entry form + // with Save / Search / Cancel / Drop / Exit) and are stored in the backend through + // InvestmentService; the other master tabs are still sample data. + private readonly investmentService = inject(InvestmentService); + partyForm: Partial = {}; + partySearchCode = ''; + scriptForm: Partial = {}; + scriptSearchCode = ''; + saving = false; + readonly bankTypeOptions = BANK_TYPE_OPTIONS; + readonly slrTypeOptions = SLR_TYPE_OPTIONS; + /** Enabled ASSET detail GLs from the chart of accounts. */ + investmentGlOptions: InvestmentGlAccount[] = []; newSubType: Partial = {}; newBroker: Partial = {}; newControlParam: Partial = {}; @@ -116,43 +130,216 @@ export class InvestmentMastersComponent { newSglBank: Partial = {}; newDailyRate: Partial = {}; + ngOnInit(): void { + this.investmentService.getAssetGlAccounts().subscribe({ + next: (gls) => (this.investmentGlOptions = gls), + error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`) + }); + this.reloadMasters(); + } + setTab(tab: MasterTab): void { this.activeTab = tab; } - addParty(): void { - if (!this.newParty.partyName || !this.newParty.partyType) { - this.toast('Enter Party Name and Type.'); - return; - } - this.parties = [ - { - partyCode: `PTY-${String(this.parties.length + 1).padStart(3, '0')}`, - panNo: '', - ...this.newParty - } as PartyMaster, - ...this.parties - ]; - this.newParty = {}; - this.toast('Party/Bank master added.'); + /** Re-reads both masters from the backend (also refreshes the lists the Purchase screen uses). */ + private reloadMasters(): void { + this.investmentService.syncSharedMasters().subscribe({ + next: ({ parties, scripts }) => { + this.parties = parties; + this.scripts = scripts; + }, + error: (err) => this.toast(`Could not load masters: ${apiErrorMessage(err)}`) + }); } - addScript(): void { - if (!this.newScript.scriptName || !this.newScript.instrumentType) { - this.toast('Enter Script Name and Instrument Type.'); + glName(glCode?: string): string { + return ( + this.investmentGlOptions.find((g) => g.glCode === glCode)?.name ?? + INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ?? + '' + ); + } + + // -------------------- Bank / Party Master -------------------- + /** Preview of the code the backend will assign; the saved code always comes from the backend. */ + get nextPartyCode(): string { + const nums = this.parties.map((p) => parseInt(p.partyCode.replace(/\D/g, ''), 10)).filter((n) => !isNaN(n)); + return `PTY-${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(3, '0')}`; + } + + saveParty(): void { + const f = this.partyForm; + if (!f.bondType || !f.partyName || !f.glAccountId) { + this.toast('Enter Bond Type, Party Name and GL A/c No.'); return; } - this.scripts = [ - { - scriptCode: this.newScript.scriptCode || `SCR-${this.scripts.length + 1}`, - isin: '', - faceValuePerUnit: 100, - ...this.newScript - } as ScriptMaster, - ...this.scripts - ]; - this.newScript = {}; - this.toast('Script master added.'); + const body = { + bondType: f.bondType, + partyName: f.partyName, + address1: f.address1 ?? '', + address2: f.address2 ?? '', + address3: f.address3 ?? '', + city: f.city ?? '', + pincode: f.pincode ?? '', + phone: f.phone ?? '', + fax: f.fax ?? '', + bankType: f.bankType ?? '', + glAccountId: Number(f.glAccountId), + rbiCode: f.rbiCode ?? '', + limit: f.limit ?? null, + leiNo: f.leiNo ?? '', + cpBankName: f.cpBankName ?? '', + cpBranchName: f.cpBranchName ?? '', + cpAccountNo: f.cpAccountNo ?? '', + cpIfsc: f.cpIfsc ?? '', + active: f.status !== 'INACTIVE', + locale: 'en' + }; + const request = f.id ? this.investmentService.updateParty(f.id, body) : this.investmentService.createParty(body); + this.saving = true; + request.subscribe({ + next: (res) => { + this.saving = false; + this.toast(`Party ${res.resourceIdentifier ?? f.partyCode} ${f.id ? 'updated' : 'saved'}.`); + this.partyForm = {}; + this.reloadMasters(); + }, + error: (err) => { + this.saving = false; + this.toast(apiErrorMessage(err)); + } + }); + } + + searchParty(): void { + const p = this.parties.find((x) => x.partyCode.toUpperCase() === this.partySearchCode.trim().toUpperCase()); + if (!p) { + this.toast(`Party Code ${this.partySearchCode} not found.`); + return; + } + this.partyForm = { ...p }; + this.toast(`Party ${p.partyCode} loaded.`); + } + + cancelParty(): void { + this.partyForm = {}; + this.partySearchCode = ''; + } + + dropParty(): void { + const f = this.partyForm; + if (!f.id) { + this.toast('Search a saved party first, then Drop.'); + return; + } + this.investmentService.deleteParty(f.id).subscribe({ + next: () => { + this.toast(`Party ${f.partyCode} dropped.`); + this.partyForm = {}; + this.reloadMasters(); + }, + error: (err) => this.toast(apiErrorMessage(err)) + }); + } + + // -------------------- Script Master -------------------- + /** Coupon / maturity terms only apply to debt securities. */ + get scriptIsDebtSecurity(): boolean { + return [ + 'GOVT_SECURITY', + 'NON_SLR', + 'BOND' + ].includes(this.scriptForm.instrumentType ?? ''); + } + + /** Preview of the code the backend will assign — a type letter + running number, e.g. G00001. */ + get nextScriptCode(): string { + const prefixByType: Record = { + GOVT_SECURITY: 'G', + NON_SLR: 'N', + BOND: 'B', + MUTUAL_FUND: 'M', + FD: 'F', + SHARE: 'S' + }; + const type = this.scriptForm.instrumentType; + if (!type) { + return ''; + } + const prefix = prefixByType[type]; + const pattern = new RegExp(`^${prefix}(\\d{5})$`); + const nums = this.scripts + .map((s) => pattern.exec(s.scriptCode)?.[1]) + .filter((n): n is string => !!n) + .map((n) => parseInt(n, 10)); + return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(5, '0')}`; + } + + get scriptPeriod(): { years: number; months: number; days: number } { + return periodBetween(this.scriptForm.issueDate, this.scriptForm.maturityDate); + } + + saveScript(): void { + const f = this.scriptForm; + if (!f.instrumentType || !f.scriptName || !f.glAccountId) { + this.toast('Enter Bond Type, Script Desc and GL A/c No.'); + return; + } + if (this.scriptIsDebtSecurity && (!f.isin || f.couponRate == null || !f.issueDate)) { + this.toast('Enter ISIN, Int Rate and Issue Date for this security.'); + return; + } + const body = { + bondType: f.instrumentType, + slrType: f.slrType ?? '', + scriptDesc: f.scriptName, + glAccountId: Number(f.glAccountId), + isin: f.isin ?? '', + intRate: f.couponRate ?? null, + issueDate: f.issueDate ?? '', + maturityDate: f.maturityDate ?? '', + companyName: f.companyName ?? '', + address1: f.address1 ?? '', + address2: f.address2 ?? '', + address3: f.address3 ?? '', + city: f.city ?? '', + pincode: f.pincode ?? '', + phone: f.phone ?? '', + fax: f.fax ?? '', + corpCode: f.corpCode ?? '', + dateFormat: 'dd-MM-yyyy', + locale: 'en' + }; + const request = f.id ? this.investmentService.updateScript(f.id, body) : this.investmentService.createScript(body); + this.saving = true; + request.subscribe({ + next: (res) => { + this.saving = false; + this.toast(`Script ${res.resourceIdentifier ?? f.scriptCode} ${f.id ? 'updated' : 'saved'}.`); + this.scriptForm = {}; + this.reloadMasters(); + }, + error: (err) => { + this.saving = false; + this.toast(apiErrorMessage(err)); + } + }); + } + + searchScript(): void { + const s = this.scripts.find((x) => x.scriptCode.toUpperCase() === this.scriptSearchCode.trim().toUpperCase()); + if (!s) { + this.toast(`Script Code ${this.scriptSearchCode} not found.`); + return; + } + this.scriptForm = { ...s }; + this.toast(`Script ${s.scriptCode} loaded.`); + } + + cancelScript(): void { + this.scriptForm = {}; + this.scriptSearchCode = ''; } addSubType(): void { @@ -279,46 +466,121 @@ export class InvestmentMastersComponent { return one( [ 'Party Code', + 'Bond Type', 'Party Name', - 'Type', - 'PAN No' + 'Address', + 'City', + 'Pincode', + 'Phone', + 'Fax', + 'Bank Type', + 'GL A/c No', + 'RBI Code', + 'Limit', + 'LEI No', + 'Counterparty Bank', + 'Branch', + 'Account No', + 'IFSC', + 'Status' ], [ 12, + 14, 30, - 16, - 14 + 36, + 14, + 10, + 14, + 14, + 24, + 12, + 12, + 14, + 22, + 24, + 20, + 18, + 14, + 10 ], this.parties.map((p) => [ p.partyCode, + p.bondType ?? '', p.partyName, - p.partyType, - p.panNo + [ + p.address1, + p.address2, + p.address3 + ] + .filter((x) => !!x) + .join(', '), + p.city ?? '', + p.pincode ?? '', + p.phone ?? '', + p.fax ?? '', + p.bankType ?? '', + p.glCode ?? '', + p.rbiCode ?? '', + p.limit ?? null, + p.leiNo ?? '', + p.cpBankName ?? '', + p.cpBranchName ?? '', + p.cpAccountNo ?? '', + p.cpIfsc ?? '', + p.status ?? 'ACTIVE' ]) ); case 'script': return one( [ 'Script Code', - 'Script Name', - 'Instrument', + 'Bond Type', + 'SLR', + 'Script Desc', + 'GL A/c No', 'ISIN', - 'Face Value/Unit' + 'Int Rate %', + 'Issue Date', + 'Maturity Date', + 'Period (Y/M/D)', + 'Face Value/Unit', + 'Company Name', + 'Corp Code' ], [ + 14, 16, + 12, 30, + 12, 16, - 16, - 16 + 10, + 14, + 14, + 14, + 14, + 24, + 12 ], - this.scripts.map((s) => [ - s.scriptCode, - s.scriptName, - s.instrumentType, - s.isin, - s.faceValuePerUnit - ]) + this.scripts.map((s) => { + const p = periodBetween(s.issueDate, s.maturityDate); + return [ + s.scriptCode, + s.instrumentType, + s.slrType ?? '', + s.scriptName, + s.glCode ?? '', + s.isin, + s.couponRate ?? null, + s.issueDate ?? '', + s.maturityDate ?? '', + s.issueDate && s.maturityDate ? `${p.years}/${p.months}/${p.days}` : '', + s.faceValuePerUnit, + s.companyName ?? '', + s.corpCode ?? '' + ]; + }) ); case 'subtype': return one( diff --git a/src/app/investment/investment.service.ts b/src/app/investment/investment.service.ts new file mode 100644 index 000000000..52a92ac78 --- /dev/null +++ b/src/app/investment/investment.service.ts @@ -0,0 +1,300 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Observable, forkJoin } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; +import { + InstrumentType, + InvestmentHolding, + PARTY_MASTER, + PartyMaster, + SCRIPT_MASTER, + ScriptMaster, + SlrType +} from './investment-data'; + +/** Investment party as returned by /investment/parties. */ +export interface InvestmentPartyDto { + id: number; + partyCode: string; + bondType: InstrumentType; + partyName: string; + address1?: string; + address2?: string; + address3?: string; + city?: string; + pincode?: string; + phone?: string; + fax?: string; + bankType?: string; + glAccountId: number; + glCode: string; + glName: string; + rbiCode?: string; + limit?: number; + leiNo?: string; + cpBankName?: string; + cpBranchName?: string; + cpAccountNo?: string; + cpIfsc?: string; + active: boolean; +} + +/** Investment script as returned by /investment/scripts (dates are DD-MM-YYYY). */ +export interface InvestmentScriptDto { + id: number; + scriptCode: string; + bondType: InstrumentType; + slrType?: SlrType; + scriptDesc: string; + glAccountId: number; + glCode: string; + glName: string; + isin?: string; + faceValuePerUnit: number; + intRate?: number; + issueDate?: string; + maturityDate?: string; + companyName?: string; + address1?: string; + address2?: string; + address3?: string; + city?: string; + pincode?: string; + phone?: string; + fax?: string; + corpCode?: string; + active: boolean; +} + +/** Purchase entry (holding) as returned by /investment/purchases (dates are DD-MM-YYYY). */ +export interface InvestmentPurchaseDto { + id: number; + dealNo: string; + certNo: string; + bondType: InstrumentType; + scriptId: number; + scriptCode: string; + scriptDesc: string; + isin?: string; + partyId: number; + partyCode: string; + partyName: string; + dealDate: string; + dealTime?: string; + settlementType: string; + settlementDate: string; + faceValue: number; + purchaseRate: number; + units: number; + balanceUnits: number; + brokerage?: number; + stampDuty?: number; + bookValue: number; + premiumDiscount?: number; + intRate?: number; + issueDate?: string; + maturityDate?: string; + lastIntDate?: string; + accruedDays?: number; + accruedInterest?: number; + settlementAmount: number; + ytm?: number; + category: string; + subType?: string; + investmentGlAccountId: number; + investmentGlCode: string; + investmentGlName: string; + crGlAccountId: number; + crGlCode: string; + crGlName: string; + otherDetails?: string; + status: string; +} + +export interface InvestmentGlAccount { + id: number; + glCode: string; + name: string; +} + +/** + * Backend calls for the Investment masters (Bank / Party Master and Script Master). Results are also copied into the + * shared PARTY_MASTER / SCRIPT_MASTER lists so screens that still read those lists (Purchase Entry) offer the saved + * masters. + */ +@Injectable({ providedIn: 'root' }) +export class InvestmentService { + private http = inject(HttpClient); + + // -------------------- Bank / Party Master -------------------- + getParties(): Observable { + return this.http.get('/investment/parties'); + } + + createParty(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> { + return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/parties', body); + } + + updateParty(id: number, body: object): Observable<{ resourceId: number; resourceIdentifier: string }> { + return this.http.put<{ resourceId: number; resourceIdentifier: string }>(`/investment/parties/${id}`, body); + } + + deleteParty(id: number): Observable { + return this.http.delete(`/investment/parties/${id}`); + } + + // -------------------- Script Master -------------------- + getScripts(): Observable { + return this.http.get('/investment/scripts'); + } + + createScript(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> { + return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/scripts', body); + } + + updateScript(id: number, body: object): Observable<{ resourceId: number; resourceIdentifier: string }> { + return this.http.put<{ resourceId: number; resourceIdentifier: string }>(`/investment/scripts/${id}`, body); + } + + // -------------------- Purchase Entry -------------------- + getPurchases(): Observable { + return this.http.get('/investment/purchases'); + } + + createPurchase(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> { + return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/purchases', body); + } + + deletePurchase(id: number): Observable { + return this.http.delete(`/investment/purchases/${id}`); + } + + // -------------------- GL accounts -------------------- + /** Enabled ASSET detail GLs — the only kind an investment party / script can be booked to. */ + getAssetGlAccounts(): Observable { + return this.http + .get< + Array<{ id: number; glCode: string; name: string; disabled: boolean }> + >('/glaccounts', { params: { type: '1', usage: '1', disabled: 'false' } }) + .pipe( + map((list) => + list + .map((g) => ({ id: g.id, glCode: g.glCode, name: g.name })) + .sort((a, b) => a.glCode.localeCompare(b.glCode)) + ) + ); + } + + /** Loads both masters from the backend and refreshes the shared lists other screens read. */ + syncSharedMasters(): Observable<{ parties: PartyMaster[]; scripts: ScriptMaster[] }> { + return forkJoin({ parties: this.getParties(), scripts: this.getScripts() }).pipe( + map(({ parties, scripts }) => ({ parties: parties.map(toPartyMaster), scripts: scripts.map(toScriptMaster) })), + tap(({ parties, scripts }) => { + PARTY_MASTER.splice(0, PARTY_MASTER.length, ...parties); + SCRIPT_MASTER.splice(0, SCRIPT_MASTER.length, ...scripts); + }) + ); + } +} + +export function toPartyMaster(p: InvestmentPartyDto): PartyMaster { + return { + id: p.id, + partyCode: p.partyCode, + partyName: p.partyName, + partyType: 'BANK', + bondType: p.bondType, + address1: p.address1, + address2: p.address2, + address3: p.address3, + city: p.city, + pincode: p.pincode, + phone: p.phone, + fax: p.fax, + bankType: p.bankType, + glAccountId: p.glAccountId, + glCode: p.glCode, + glName: p.glName, + rbiCode: p.rbiCode, + limit: p.limit ?? null, + leiNo: p.leiNo, + cpBankName: p.cpBankName, + cpBranchName: p.cpBranchName, + cpAccountNo: p.cpAccountNo, + cpIfsc: p.cpIfsc, + status: p.active ? 'ACTIVE' : 'INACTIVE' + }; +} + +export function toScriptMaster(s: InvestmentScriptDto): ScriptMaster { + return { + id: s.id, + scriptCode: s.scriptCode, + scriptName: s.scriptDesc, + instrumentType: s.bondType, + isin: s.isin ?? '', + faceValuePerUnit: s.faceValuePerUnit, + slrType: s.slrType, + glAccountId: s.glAccountId, + glCode: s.glCode, + glName: s.glName, + couponRate: s.intRate, + issueDate: s.issueDate, + maturityDate: s.maturityDate, + companyName: s.companyName, + address1: s.address1, + address2: s.address2, + address3: s.address3, + city: s.city, + pincode: s.pincode, + phone: s.phone, + fax: s.fax, + corpCode: s.corpCode, + active: s.active + }; +} + +/** A saved purchase as a row of the Holdings register. */ +export function toHolding(p: InvestmentPurchaseDto): InvestmentHolding { + return { + id: p.id, + dealNo: p.dealNo, + certNo: p.certNo, + instrumentType: p.bondType, + scriptCode: p.scriptCode, + scriptName: p.scriptDesc, + party: p.partyName, + partyCode: p.partyCode, + subType: p.subType ?? '', + faceValue: p.faceValue, + purchaseRate: p.purchaseRate, + units: p.balanceUnits, + purchaseDate: p.settlementDate, + maturityDate: p.maturityDate || p.dealDate, + interestRate: p.intRate ?? 0, + interestFreq: p.bondType === 'MUTUAL_FUND' ? 'CUMULATIVE' : 'HALF_YEARLY', + glCode: p.investmentGlCode, + glName: p.investmentGlName, + crToAc: p.crGlCode, + brokerCode: '', + status: 'ACTIVE', + category: p.category, + bookValue: p.bookValue, + settlementAmount: p.settlementAmount + }; +} + +/** The first validation / domain-rule message from a Fineract error response, for a toast. */ +export function apiErrorMessage(err: unknown): string { + const e = err as HttpErrorResponse; + const body = e?.error; + return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || e?.message || 'Request failed.'; +}
Script CodeScript NameInstrumentBond TypeSLRScript DescGL A/c ISINFace Value/UnitInt Rate %Issue DateMaturity
{{ s.scriptCode }} {{ s.scriptName }} {{ s.instrumentType }}{{ s.slrType || '—' }}{{ s.scriptName }}{{ s.glCode || '—' }} {{ s.isin || '—' }}{{ s.faceValuePerUnit }}{{ s.couponRate ?? '—' }}{{ s.issueDate || '—' }}{{ s.maturityDate || '—' }}