From 3c09fb5dbaccf6ebaca733c344c5960b7110b588 Mon Sep 17 00:00:00 2001 From: anshul Date: Thu, 29 Oct 2026 13:55:43 +0530 Subject: [PATCH] feat: add bond and share investment management modules with report and deal slip generation --- .../bond-investment.component.html | 104 ++++- .../bond-investment.component.ts | 75 +++- src/app/investment/deal-slip-pdf.ts | 359 ++++++++++++++++++ .../investment-reports.component.html | 127 ++++++- .../investment-reports.component.ts | 191 +++++++++- src/app/investment/investment.service.ts | 133 ++++++- .../share-investment.component.html | 95 ++++- .../share-investment.component.ts | 73 +++- 8 files changed, 1106 insertions(+), 51 deletions(-) create mode 100644 src/app/investment/deal-slip-pdf.ts diff --git a/src/app/investment/bond-investment/bond-investment.component.html b/src/app/investment/bond-investment/bond-investment.component.html index 2bed4eca8..58b0b1d10 100644 --- a/src/app/investment/bond-investment/bond-investment.component.html +++ b/src/app/investment/bond-investment/bond-investment.component.html @@ -128,6 +128,34 @@ @if (activeTab === 'holdings') {
+ @if (settleTarget?.kind === 'purchase') { + +
+ + +
+

+ Record the counterparty's confirmation or contract note if it has come; leave it blank otherwise and the Deal + Slip Register shows it as awaited. +

+
+ + +
+ } @@ -173,14 +201,19 @@ } @else if (h.glTransactionId) { GL {{ h.glTransactionId }} } + @if (h.dealNo) { + + } } @empty { @@ -238,6 +271,15 @@ >* Deal Time (HH:mm:ss) + + + + +

+ Record the counterparty's confirmation or contract note if it has come; leave it blank otherwise and the Deal + Slip Register shows it as awaited. +

+
+ + +
+ }
- + @@ -1203,7 +1291,10 @@ @for (s of pendingSalesForType; track s.id) { @@ -1216,8 +1307,8 @@ @@ -1229,6 +1320,7 @@ > Drop + } @empty { diff --git a/src/app/investment/bond-investment/bond-investment.component.ts b/src/app/investment/bond-investment/bond-investment.component.ts index 78995786d..b2cec1b30 100644 --- a/src/app/investment/bond-investment/bond-investment.component.ts +++ b/src/app/investment/bond-investment/bond-investment.component.ts @@ -59,6 +59,7 @@ import { todayDDMMYYYY } from '../investment-data'; import { exportWorkbook } from '../investment-export'; +import { saveDealSlipPdf } from '../deal-slip-pdf'; import { InvestmentDailyRateDto, InvestmentInterestDueDto, @@ -69,6 +70,8 @@ import { InvestmentAmortisationDto, InvestmentAmortisationDueDto, InvestmentSaleDto, + CONFIRMATION_MODES, + SettleConfirmation, InvestmentSecurityTransferDto, InvestmentNonSlrLimitDto, InvestmentService, @@ -117,6 +120,8 @@ interface BondPurchaseForm { premiumAmt: number | null; brokerage: number | null; brokerName: string; + /** How the deal was confirmed to the counterparty (deal slip). */ + confirmationMode: string; tradeNo: string; orderNo: string; recommAuthority: string; @@ -553,6 +558,7 @@ export class BondInvestmentComponent implements OnInit { premiumAmt: null, brokerage: null, brokerName: '', + confirmationMode: '', tradeNo: '', orderNo: '', recommAuthority: '', @@ -999,6 +1005,7 @@ export class BondInvestmentComponent implements OnInit { crGlAccountId, subType: f.subType || '', otherDetails: this.purchaseOtherDetails(), + ...(f.confirmationMode ? { confirmationMode: f.confirmationMode } : {}), dateFormat: 'dd-MM-yyyy', locale: 'en' }; @@ -1107,18 +1114,71 @@ export class BondInvestmentComponent implements OnInit { this.toast(`Deal for Cert No ${h.certNo} retrieved.`); } + // -------------------- Settle with the counterparty's confirmation (back office) -------------------- + /** The deal the back office is settling, with the counterparty's confirmation it records (both optional). */ + settleTarget: { kind: 'purchase' | 'sale'; id: number; label: string } | null = null; + settleConfirmation: SettleConfirmation = {}; + readonly confirmationModes = CONFIRMATION_MODES; + + openPurchaseSettle(h: InvestmentHolding): void { + if (!h.id) { + return; + } + this.settleTarget = { kind: 'purchase', id: h.id, label: `deal ${h.dealNo}` }; + this.settleConfirmation = {}; + } + + openSaleSettle(s: InvestmentSaleDto): void { + if (!s.id) { + return; + } + this.settleTarget = { kind: 'sale', id: s.id, label: `sale ${s.saleNo ?? ''} of deal ${s.dealNo}` }; + this.settleConfirmation = {}; + } + + confirmSettle(): void { + const t = this.settleTarget; + if (!t) { + return; + } + if (t.kind === 'purchase') { + const h = this.holdings.find((x) => x.id === t.id); + if (h) { + this.settleDeal(h, this.settleConfirmation); + } + } else { + const s = this.sales.find((x) => x.id === t.id); + if (s) { + this.settleSale(s, this.settleConfirmation); + } + } + } + + /** Downloads the deal slip (PDF) of a purchase deal number or a sale number. */ + printDealSlip(slipNo?: string): void { + if (!slipNo) { + this.toast('This deal has no deal slip number.'); + return; + } + this.investmentService.getDealSlip(slipNo).subscribe({ + next: (rows) => (rows.length ? saveDealSlipPdf(rows[0]) : this.toast(`Deal slip ${slipNo} not found.`)), + error: (err) => this.toast(apiErrorMessage(err)) + }); + } + /** Posts a pending deal to GL dated its settlement date; under maker-checker it waits in the Checker Inbox. */ /** The purchase being settled; its button stays disabled so it cannot be sent twice. */ settlingDealId: number | null = null; - settleDeal(h: InvestmentHolding): void { + settleDeal(h: InvestmentHolding, confirmation: SettleConfirmation = {}): void { if (!h.id || this.settlingDealId !== null) { return; } this.settlingDealId = h.id; - this.investmentService.settlePurchase(h.id).subscribe({ + this.investmentService.settlePurchase(h.id, confirmation).subscribe({ next: (res) => { this.settlingDealId = null; + this.settleTarget = null; this.toast( awaitingApproval(res) ? sentForApproval(`Settlement of deal ${h.dealNo}`) @@ -1368,7 +1428,9 @@ export class BondInvestmentComponent implements OnInit { brokerage: null as number | null, bankGlCode: '', sanctionAuthority: '', - remarks: '' + remarks: '', + brokerCode: '', + confirmationMode: '' }; } @@ -1460,6 +1522,8 @@ export class BondInvestmentComponent implements OnInit { bankGlAccountId, ...(f.sanctionAuthority.trim() ? { sanctionAuthority: f.sanctionAuthority.trim() } : {}), ...(f.remarks.trim() ? { remarks: f.remarks.trim() } : {}), + ...(f.brokerCode ? { brokerCode: f.brokerCode } : {}), + ...(f.confirmationMode ? { confirmationMode: f.confirmationMode } : {}), dateFormat: 'dd-MM-yyyy', locale: 'en' }; @@ -1486,14 +1550,15 @@ export class BondInvestmentComponent implements OnInit { /** The pending sale being settled or dropped; its buttons stay disabled so it cannot be sent twice. */ busySaleId: number | null = null; - settleSale(s: InvestmentSaleDto): void { + settleSale(s: InvestmentSaleDto, confirmation: SettleConfirmation = {}): void { if (!s.id || this.busySaleId !== null) { return; } this.busySaleId = s.id; - this.investmentService.settleSale(s.id).subscribe({ + this.investmentService.settleSale(s.id, confirmation).subscribe({ next: (res) => { this.busySaleId = null; + this.settleTarget = null; if (awaitingApproval(res)) { this.toast(sentForApproval(`Settlement of the sale of deal ${s.dealNo}`)); return; diff --git a/src/app/investment/deal-slip-pdf.ts b/src/app/investment/deal-slip-pdf.ts new file mode 100644 index 000000000..985c65e2b --- /dev/null +++ b/src/app/investment/deal-slip-pdf.ts @@ -0,0 +1,359 @@ +/** + * 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/. + */ + +/** + * One-page Investment Deal Slip: the serially numbered record of a single deal (purchase, sale, share refund, + * redemption or switch) with the counterparty, security, amount, price, deal date and time, settlement date, how the + * deal was confirmed, direct or through a broker, the counterparty's confirmation, and who entered, approved and + * settled it. A cancelled (dropped) deal prints with a CANCELLED stamp so its number stays accounted for. + */ + +import { GState, jsPDF } from 'jspdf'; +import { BROKER_MASTER } from './investment-data'; +import { confirmationModeLabel, InvestmentDealSlipDto } from './investment.service'; + +const BANK_NAME = 'Nexora Co-operative Bank Ltd.'; +const C_TEXT: [number, number, number] = [ + 15, + 23, + 42 +]; +const C_MUTED: [number, number, number] = [ + 100, + 116, + 139 +]; +const C_BORDER: [number, number, number] = [ + 226, + 232, + 240 +]; +const ACCENT: [number, number, number] = [ + 30, + 64, + 175 +]; +const C_CANCELLED: [number, number, number] = [ + 220, + 38, + 38 +]; + +const SIDE_LABEL: Record = { + BUY: 'PURCHASE', + SELL: 'SALE', + REFUND: 'SHARE REFUND', + REDEEM: 'REDEMPTION', + SWITCH: 'SWITCH OUT' +}; + +const STATUS_LABEL: Record = { + PENDING_SETTLEMENT: 'Pending settlement', + SETTLED: 'Settled', + CANCELLED: 'Cancelled (dropped)' +}; + +/** Amount in rupees; the PDF's standard font has no rupee sign, so "Rs". */ +function rs(n: number | null | undefined): string { + if (n === null || n === undefined) return '—'; + return 'Rs ' + n.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function num(n: number | null | undefined, maxDecimals = 6): string { + if (n === null || n === undefined) return '—'; + return n.toLocaleString('en-IN', { maximumFractionDigits: maxDecimals }); +} + +function who(by?: string, on?: string): string { + if (!by) return '—'; + return on ? `${by} on ${on}` : by; +} + +/** Price label of the security: per Rs 100 of face value, NAV per unit, or price per share. */ +function priceLabel(bondType: string): string { + if (bondType === 'MUTUAL_FUND') return 'NAV per unit'; + if (bondType === 'SHARE') return 'Price per share'; + return 'Price (per Rs 100 face value)'; +} + +export function buildDealSlipPdf(slip: InvestmentDealSlipDto, brokerName?: string): jsPDF { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }); + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const margin = 14; + const colWidth = (pageWidth - margin * 2 - 6) / 2; + const labelWidth = 38; + let y = 16; + + doc.setFillColor(...ACCENT); + doc.rect(0, 0, pageWidth, 4, 'F'); + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(15); + doc.setTextColor(...C_TEXT); + doc.text(BANK_NAME, margin, y); + doc.setFontSize(13); + doc.text(`Slip No. ${slip.slipNo}`, pageWidth - margin, y, { align: 'right' }); + y += 6; + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(...C_MUTED); + doc.text('Treasury - Investment Deal Slip', margin, y); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(...(slip.status === 'CANCELLED' ? C_CANCELLED : ACCENT)); + doc.text( + `${SIDE_LABEL[slip.side] ?? slip.side} · ${STATUS_LABEL[slip.status] ?? slip.status}`, + pageWidth - margin, + y, + { align: 'right' } + ); + y += 5; + doc.setDrawColor(...C_BORDER); + doc.line(margin, y, pageWidth - margin, y); + y += 7; + + /** A titled block of label / value pairs in two columns; long values wrap. */ + const section = (title: string, rows: [string, string][]) => { + doc.setFont('helvetica', 'bold'); + doc.setFontSize(9.5); + doc.setTextColor(...ACCENT); + doc.text(title.toUpperCase(), margin, y); + y += 5; + for (let i = 0; i < rows.length; i += 2) { + let lines = 1; + [ + rows[i], + rows[i + 1] + ].forEach((r, col) => { + if (!r) return; + const x = margin + col * (colWidth + 6); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(8.5); + doc.setTextColor(...C_MUTED); + const label = doc.splitTextToSize(r[0], labelWidth - 3) as string[]; + doc.text(label, x, y); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9.5); + doc.setTextColor(...C_TEXT); + const value = doc.splitTextToSize(r[1] || '—', colWidth - labelWidth) as string[]; + doc.text(value, x + labelWidth, y); + lines = Math.max(lines, label.length, value.length); + }); + y += 4.6 * lines + 1.6; + } + y += 2; + doc.setDrawColor(...C_BORDER); + doc.line(margin, y, pageWidth - margin, y); + y += 6; + }; + + const buy = slip.side === 'BUY'; + const settlement = slip.settlementType ? `${slip.settlementType}, ${slip.settlementDate}` : slip.settlementDate; + section('Deal', [ + [ + 'Slip No.', + slip.slipNo + ], + [ + 'Deal type', + SIDE_LABEL[slip.side] ?? slip.side + ], + [ + 'Holding deal no.', + slip.certNo ? `${slip.dealNo} (Cert ${slip.certNo})` : slip.dealNo + ], + [ + 'Category', + slip.category ?? '—' + ], + [ + 'Deal date & time', + slip.dealTime ? `${slip.dealDate} ${slip.dealTime}` : slip.dealDate + ], + [ + 'Settlement', + settlement + ], + [ + 'Status', + STATUS_LABEL[slip.status] ?? slip.status + ], + [ + 'Voucher / GL txn', + slip.voucherNo ?? slip.glTransactionId ?? '—' + ] + ]); + + section('Counterparty and security', [ + [ + buy ? 'Seller' : 'Buyer / payer', + slip.partyCode ? `${slip.partyCode} - ${slip.counterparty ?? ''}` : (slip.counterparty ?? '—') + ], + [ + 'Instrument', + slip.bondType.replace(/_/g, ' ') + ], + [ + 'Security', + `${slip.scriptCode} - ${slip.scriptDesc}` + ], + [ + 'ISIN', + slip.isin ?? '—' + ] + ]); + + section('Amount', [ + [ + 'Units / quantity', + num(slip.units) + ], + [ + 'Face value', + rs(slip.faceValue) + ], + [ + priceLabel(slip.bondType), + num(slip.price, 4) + ], + [ + 'Consideration', + rs(slip.consideration) + ], + [ + buy ? 'Interest paid to seller' : 'Interest from buyer', + rs(slip.accruedInterest) + ], + [ + 'Brokerage', + rs(slip.brokerage) + ], + [ + 'Stamp duty / charges', + rs(slip.otherCharges) + ], + [ + buy ? 'Net amount paid' : 'Net amount received', + rs(slip.netAmount) + ], + [ + buy ? 'Paid from (bank GL)' : 'Received in (bank GL)', + slip.bankGlCode ? `${slip.bankGlCode} - ${slip.bankGlName ?? ''}` : '—' + ] + ]); + + section('Deal mode and confirmation', [ + [ + 'Direct / broker', + slip.dealMode === 'BROKER' ? 'Through a broker' : 'Direct deal' + ], + [ + 'Broker', + slip.brokerCode ? `${slip.brokerCode}${brokerName ? ' - ' + brokerName : ''}` : '—' + ], + [ + 'Trade / order no.', + [ + slip.tradeNo, + slip.orderNo + ] + .filter(Boolean) + .join(' / ') || '—' + ], + [ + 'Confirmation sent by', + confirmationModeLabel(slip.confirmationMode) || '—' + ], + [ + 'Counterparty confirmation', + slip.confirmationReceivedOn + ? `Received on ${slip.confirmationReceivedOn}${slip.confirmationRef ? ', ref ' + slip.confirmationRef : ''}` + : slip.status === 'CANCELLED' + ? '—' + : 'Awaited' + ] + ]); + + section('Processing (maker-checker)', [ + [ + 'Entered by', + who(slip.enteredBy, slip.enteredOn) + ], + [ + 'Approved by', + who(slip.approvedBy, slip.approvedOn) + ], + [ + 'Settled by', + who(slip.settledBy, slip.settledOn) + ], + [ + 'Settlement approved by', + who(slip.settleApprovedBy, slip.settleApprovedOn) + ], + ...(slip.status === 'CANCELLED' ? ([ + [ + 'Cancelled by', + who(slip.cancelledBy, slip.cancelledOn) + ] + ] as [string, string][]) : []) + ]); + + if (slip.remarks) { + section('Remarks', [[ + 'Remarks', + slip.remarks + ]]); + } + + // Signatures: the dealer, the back office that checked the confirmation, and the officer who authorised the deal. + const sigY = Math.max(y + 18, pageHeight - 40); + const sigWidth = (pageWidth - margin * 2 - 16) / 3; + doc.setDrawColor(...C_TEXT); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + doc.setTextColor(...C_MUTED); + [ + 'Dealer', + 'Back Office', + 'Authorised Officer' + ].forEach((label, i) => { + const x = margin + i * (sigWidth + 8); + doc.line(x, sigY, x + sigWidth, sigY); + doc.text(label, x, sigY + 5); + }); + doc.setFontSize(7.5); + doc.text(`Printed on ${new Date().toLocaleString('en-IN')}`, margin, pageHeight - 10); + + if (slip.status === 'CANCELLED') { + doc.setFont('helvetica', 'bold'); + doc.setFontSize(64); + doc.setTextColor(...C_CANCELLED); + doc.setGState(new GState({ opacity: 0.18 })); + // Rotated text is placed from its start, so step back half its width along the slope to centre it. + const half = doc.getTextWidth('CANCELLED') / 2; + const slope = (35 * Math.PI) / 180; + doc.text('CANCELLED', pageWidth / 2 - half * Math.cos(slope), pageHeight / 2 + half * Math.sin(slope), { + angle: 35 + }); + doc.setGState(new GState({ opacity: 1 })); + } + + return doc; +} + +/** Broker name from the Broker Master, for a broker code on a deal. */ +export function brokerNameOf(code?: string | null): string | undefined { + return code ? BROKER_MASTER.find((b) => b.brokerCode === code)?.brokerName : undefined; +} + +/** Builds the slip and downloads it as deal-slip-.pdf. */ +export function saveDealSlipPdf(slip: InvestmentDealSlipDto): void { + buildDealSlipPdf(slip, brokerNameOf(slip.brokerCode)).save(`deal-slip-${slip.slipNo}.pdf`); +} diff --git a/src/app/investment/investment-reports/investment-reports.component.html b/src/app/investment/investment-reports/investment-reports.component.html index 492937a58..cdf0122b7 100644 --- a/src/app/investment/investment-reports/investment-reports.component.html +++ b/src/app/investment/investment-reports/investment-reports.component.html @@ -264,8 +264,7 @@ activeReport === 'fdLedger' || activeReport === 'bondLedger' || activeReport === 'shareLedger' || - activeReport === 'amortizeLedger' || - activeReport === 'dealSlip' + activeReport === 'amortizeLedger' ) {
Deal NoSale No / Deal No Script Buyer Units
- {{ s.dealNo }} + {{ s.saleNo || s.dealNo }} + @if (s.saleNo) { +
Deal {{ s.dealNo }} + }
{{ s.scriptCode }} — {{ s.scriptDesc }} {{ s.partyName }}
@@ -300,9 +297,7 @@ ? bondTxns : activeReport === 'shareLedger' ? shareTxns - : activeReport === 'amortizeLedger' - ? amortizeTxns - : dealSlips; + : amortizeTxns; track $index ) { @@ -324,6 +319,122 @@
} + @if (activeReport === 'dealSlip') { +
+ +
+ + +
+
+ +
+ @if (slipError) { +

{{ slipError }}

+ } + @if (slipGaps.length) { +

+ Missing slip numbers in this period: {{ slipGaps.join(', ') }}. Every number must be accounted for. +

+ } @else if (slips.length) { +

No gaps: every slip number in this period is accounted for.

+ } + @if (slipsAwaited) { +

+ Counterparty confirmation still awaited on {{ slipsAwaited }} deal{{ slipsAwaited === 1 ? '' : 's' }} + (marked below). +

+ } + + + + + + + + + + + + + + + + + + + + @for (s of slips; track s.slipNo) { + + + + + + + + + + + + + + + + } @empty { + + + + } + +
Slip NoTypeDeal Date / TimeCounterpartySecurityUnitsPriceNet AmountBrokerConfirmationEntered / ApprovedStatus
+ {{ s.slipNo }} + @if (s.side !== 'BUY') { +
Deal {{ s.dealNo }} + } +
{{ s.side }} + {{ s.dealDate }}
{{ s.dealTime || '—' }} · {{ s.settlementType }} {{ s.settlementDate }} +
{{ s.counterparty || '—' }} + {{ s.scriptCode }} {{ s.scriptDesc }} + @if (s.isin) { +
{{ s.isin }} + } +
{{ s.units | number: '1.0-6' }}{{ s.price | number: '1.2-4' }}₹{{ s.netAmount | number: '1.2-2' }} + @if (s.dealMode === 'BROKER') { + {{ s.brokerCode }}
{{ brokerName(s.brokerCode) }} · ₹{{ s.brokerage | number: '1.2-2' }} + } @else { + Direct + } +
+ {{ confirmationLabel(s.confirmationMode) || '—' }}
+ @if (s.confirmationReceivedOn) { + Received {{ s.confirmationReceivedOn + }}{{ s.confirmationRef ? ' · ' + s.confirmationRef : '' }} + } @else if (confirmationAwaited(s)) { + Awaited + } +
+ {{ s.enteredBy || '—' }}
{{ s.approvedBy ? 'Approved: ' + s.approvedBy : '' }} +
+ {{ + s.status === 'PENDING_SETTLEMENT' ? 'PENDING' : s.status + }} + + +
{{ slipLoading ? 'Loading…' : 'No deals in this period.' }}
+
+ } @if (activeReport === 'intDueRegister') {