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') {
{{ instrumentLabel(bondType) }} Holdings Register
+ @if (settleTarget?.kind === 'purchase') { +
Settle {{ settleTarget?.label }} — counterparty confirmation (back office)
+
+ + +
+

+ 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') { +
+
Deal Slip Register — every deal serially numbered, cancelled ones included
+
+ + +
+
+ +
+ @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') {
diff --git a/src/app/investment/investment-reports/investment-reports.component.ts b/src/app/investment/investment-reports/investment-reports.component.ts index 433b22a37..61c8ea240 100644 --- a/src/app/investment/investment-reports/investment-reports.component.ts +++ b/src/app/investment/investment-reports/investment-reports.component.ts @@ -8,6 +8,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -21,8 +22,11 @@ import { todayDDMMYYYY } from '../investment-data'; import { exportWorkbook, ExportSheetSpec } from '../investment-export'; +import { brokerNameOf, saveDealSlipPdf } from '../deal-slip-pdf'; +import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component'; import { InvestmentDailyRateDto, + InvestmentDealSlipDto, InvestmentFdDto, InvestmentFdScheduleDto, InvestmentInterestReportRowDto, @@ -30,6 +34,7 @@ import { InvestmentService, apiErrorMessage, bookValueSoldByPurchase, + confirmationModeLabel, toFdHolding, toFdTxns, toHolding, @@ -57,7 +62,9 @@ type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio'; standalone: true, imports: [ CommonModule, - RouterLink + FormsModule, + RouterLink, + DateDdmmyyyyPickerComponent ] }) export class InvestmentReportsComponent implements OnInit { @@ -80,8 +87,17 @@ export class InvestmentReportsComponent implements OnInit { /** Interest received in the financial year to the as-on date, per deal / FD (Interest Received & Receivable report). */ receivedRows: InvestmentInterestReportRowDto[] = []; receivedFrom = ''; - /** Every purchase deal, pending ones included — the Deal Slip register (the ledgers hold only what reached the GL). */ - dealSlipTxns: InvestmentTxn[] = []; + /** + * Deal Slip Register: every purchase, sale, refund, redemption and switch dated in the period, cancelled ones + * included, in slip number order (loaded when the report is opened). + */ + slips: InvestmentDealSlipDto[] = []; + slipFrom = ''; + slipTo = todayDDMMYYYY(); + slipLoading = false; + slipError = ''; + /** Numbers missing between the first and last slip of each series in the period. */ + slipGaps: string[] = []; /** GOI category transfers (security shifted to another script / category). */ transfers: InvestmentSecurityTransferDto[] = []; /** FD interest lines need one call per FD, so they load when a report needs them. */ @@ -128,7 +144,6 @@ export class InvestmentReportsComponent implements OnInit { ...r.transfers.map(toTransferTxn), ...r.fds.flatMap((f) => toFdTxns(f, [])) ]); - this.dealSlipTxns = this.byDateDesc(r.purchases.map(toPurchaseTxn)); // Received = coupons, FD payouts and FD interest paid at closure, as the interest report counts them. this.receivedRows = r.interest.rows.filter((x) => x.received > 0); this.receivedFrom = r.interest.fromDate; @@ -211,6 +226,80 @@ export class InvestmentReportsComponent implements OnInit { if (report === 'fdLedger') { this.loadFdInterest(); } + if (report === 'dealSlip' && !this.slips.length && !this.slipLoading) { + this.loadDealSlips(); + } + } + + /** Deal Slip Register for the period (default: the financial year to date). */ + loadDealSlips(): void { + if (!this.slipFrom) { + const d = parseDDMMYYYY(this.slipTo); + this.slipFrom = `01-04-${d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1}`; + } + this.slipLoading = true; + this.slipError = ''; + this.investmentService.getDealSlips(this.slipFrom, this.slipTo).subscribe({ + next: (rows) => { + this.slips = rows; + this.slipGaps = this.findSlipGaps(rows); + this.slipLoading = false; + }, + error: (err: unknown) => { + this.slips = []; + this.slipGaps = []; + this.slipLoading = false; + this.slipError = apiErrorMessage(err); + } + }); + } + + /** + * Missing numbers in each series (purchases 202627000001…, sales 202627S00001…) between the first and the last + * slip shown: every slip must be accounted for, cancelled ones included. + */ + private findSlipGaps(rows: InvestmentDealSlipDto[]): string[] { + const series = new Map(); + for (const r of rows) { + const m = /^(\d{6}S?)(\d+)$/.exec(r.slipNo); + if (!m) { + continue; + } + const s = series.get(m[1]) ?? { width: m[2].length, nos: [] }; + s.nos.push(Number(m[2])); + series.set(m[1], s); + } + const gaps: string[] = []; + series.forEach((s, prefix) => { + const nos = new Set(s.nos); + for (let n = Math.min(...s.nos) + 1; n < Math.max(...s.nos); n++) { + if (!nos.has(n)) { + gaps.push(prefix + String(n).padStart(s.width, '0')); + } + } + }); + return gaps; + } + + /** A deal settled without the counterparty's confirmation recorded (cancelled deals need none). */ + confirmationAwaited(s: InvestmentDealSlipDto): boolean { + return s.status !== 'CANCELLED' && !s.confirmationReceivedOn && s.side !== 'REDEEM'; + } + + get slipsAwaited(): number { + return this.slips.filter((s) => this.confirmationAwaited(s)).length; + } + + printSlip(s: InvestmentDealSlipDto): void { + saveDealSlipPdf(s); + } + + confirmationLabel(mode?: string): string { + return confirmationModeLabel(mode); + } + + brokerName(code?: string): string { + return brokerNameOf(code) ?? ''; } instrumentLabel(type: InstrumentType): string { @@ -298,9 +387,6 @@ export class InvestmentReportsComponent implements OnInit { get amortizeTxns(): InvestmentTxn[] { return this.txns.filter((t) => t.txnType === 'AMORTIZE'); } - get dealSlips(): InvestmentTxn[] { - return this.dealSlipTxns; - } get slrBondRegister(): InvestmentHolding[] { return this.active('GOVT_SECURITY'); } @@ -542,13 +628,90 @@ export class InvestmentReportsComponent implements OnInit { this.amortizeTxns.map((t) => this.txnExportRow(t)) ); case 'dealSlip': - return one( - 'Deal Slip', - 'Deal Slip Register', - this.txnExportHeaders, - this.txnExportWidths, - this.dealSlips.map((t) => this.txnExportRow(t)) - ); + return [ + { + sheetName: 'Deal Slip Register', + title: 'Deal Slip Register', + subtitle: + `Deals dated ${this.slipFrom} to ${this.slipTo}` + + (this.slipGaps.length ? ` · missing numbers: ${this.slipGaps.join(', ')}` : ''), + headers: [ + 'Slip No', + 'Type', + 'Status', + 'Deal Date', + 'Deal Time', + 'Settlement', + 'Counterparty', + 'Security', + 'ISIN', + 'Units', + 'Price', + 'Consideration', + 'Brokerage', + 'Net Amount', + 'Direct / Broker', + 'Broker', + 'Confirmation Sent By', + 'Confirmation Received', + 'Confirmation Ref', + 'Entered By', + 'Approved By', + 'Settled By', + 'Cancelled By' + ], + widths: [ + 16, + 10, + 18, + 12, + 10, + 18, + 28, + 32, + 14, + 12, + 10, + 16, + 12, + 16, + 12, + 24, + 18, + 16, + 18, + 24, + 24, + 24, + 24 + ], + rows: this.slips.map((s) => [ + s.slipNo, + s.side, + s.status, + s.dealDate, + s.dealTime ?? '', + `${s.settlementType ?? ''} ${s.settlementDate}`.trim(), + s.counterparty ?? '', + `${s.scriptCode} ${s.scriptDesc}`, + s.isin ?? '', + s.units, + s.price ?? null, + s.consideration, + s.brokerage ?? 0, + s.netAmount, + s.dealMode === 'BROKER' ? 'Broker' : 'Direct', + s.brokerCode ? `${s.brokerCode} ${this.brokerName(s.brokerCode)}`.trim() : '', + this.confirmationLabel(s.confirmationMode), + s.confirmationReceivedOn ?? (this.confirmationAwaited(s) ? 'Awaited' : ''), + s.confirmationRef ?? '', + s.enteredBy ? `${s.enteredBy} ${s.enteredOn ?? ''}`.trim() : '', + s.approvedBy ? `${s.approvedBy} ${s.approvedOn ?? ''}`.trim() : '', + s.settledBy ? `${s.settledBy} ${s.settledOn ?? ''}`.trim() : '', + s.cancelledBy ? `${s.cancelledBy} ${s.cancelledOn ?? ''}`.trim() : '' + ]) + } + ]; case 'intDueRegister': return one( 'Interest Due Register', diff --git a/src/app/investment/investment.service.ts b/src/app/investment/investment.service.ts index 529bfea23..ae3c750ab 100644 --- a/src/app/investment/investment.service.ts +++ b/src/app/investment/investment.service.ts @@ -434,6 +434,11 @@ export interface InvestmentPurchaseDto { crGlCode: string; crGlName: string; otherDetails?: string; + /** How the deal was confirmed to the counterparty (deal slip). */ + confirmationMode?: ConfirmationMode; + /** When the counterparty's confirmation reached the back office, recorded on Settle. */ + confirmationReceivedOn?: string; + confirmationRef?: string; status: string; glTransactionId?: string; settledOnDate?: string; @@ -517,9 +522,15 @@ export class InvestmentService { return this.http.post<{ resourceId?: number; resourceIdentifier?: string }>('/investment/sales', body); } - /** Posts a pending sale on or after its settlement date; the voucher number comes back as resourceIdentifier. */ - settleSale(saleId: number): Observable<{ resourceIdentifier?: string }> { - return this.http.post<{ resourceIdentifier?: string }>(`/investment/sales/${saleId}/settle`, {}); + /** + * Posts a pending sale on or after its settlement date; the voucher number comes back as resourceIdentifier. The + * back office may record the counterparty's confirmation with it. + */ + settleSale(saleId: number, confirmation: SettleConfirmation = {}): Observable<{ resourceIdentifier?: string }> { + return this.http.post<{ resourceIdentifier?: string }>( + `/investment/sales/${saleId}/settle`, + settleConfirmationBody(confirmation) + ); } deleteSale(saleId: number): Observable { @@ -636,14 +647,30 @@ export class InvestmentService { return this.http.delete(`/investment/daily-rates/${rateId}`); } - /** Settles a deal: posts it to GL dated its settlement date. Under maker-checker the response only says it awaits approval. */ - settlePurchase(purchaseId: number): Observable<{ transactionId?: string; rollbackTransaction?: boolean }> { + /** + * Settles a deal: posts it to GL dated its settlement date. Under maker-checker the response only says it awaits + * approval. The back office may record the counterparty's confirmation with it. + */ + settlePurchase( + purchaseId: number, + confirmation: SettleConfirmation = {} + ): Observable<{ transactionId?: string; rollbackTransaction?: boolean }> { return this.http.post<{ transactionId?: string; rollbackTransaction?: boolean }>( `/investment/purchases/${purchaseId}/settle`, - {} + settleConfirmationBody(confirmation) ); } + /** Deal slip register: deals dated fromDate to toDate (DD-MM-YYYY), cancelled ones included, in slip number order. */ + getDealSlips(fromDate: string, toDate: string): Observable { + return this.http.get('/investment/deal-slips', { params: { fromDate, toDate } }); + } + + /** One deal slip by its number: a purchase deal number or a sale number. */ + getDealSlip(slipNo: string): Observable { + return this.http.get('/investment/deal-slips', { params: { slipNo } }); + } + /** RBI non-SLR limits for a purchase of `amount` (book value) in a script on a deal date (DD-MM-YYYY). */ getNonSlrLimit(scriptId: number | null, dealDate: string, amount: number): Observable { const params: Record = { dealDate, amount: String(amount || 0) }; @@ -1182,6 +1209,8 @@ export interface InvestmentSaleDto { id?: number; purchaseId: number; dealNo: string; + /** Deal slip number of the sale itself, e.g. 202627S00001 (none for a write-off or amortisation). */ + saleNo?: string; certNo: string; bondType: string; scriptCode: string; @@ -1215,12 +1244,104 @@ export interface InvestmentSaleDto { investmentGlCode: string; sanctionAuthority?: string; remarks?: string; + brokerCode?: string; + confirmationMode?: ConfirmationMode; + confirmationReceivedOn?: string; + confirmationRef?: string; status: 'PENDING_SETTLEMENT' | 'SETTLED'; glTransactionId?: string; voucherNo?: string; settledOnDate?: string; } +/** How a deal was confirmed to the counterparty. */ +export type ConfirmationMode = 'EMAIL' | 'NDS_OM' | 'FAX' | 'LETTER' | 'PHONE'; + +export const CONFIRMATION_MODES: Array<{ value: ConfirmationMode; label: string }> = [ + { value: 'NDS_OM', label: 'NDS-OM / CCIL' }, + { value: 'EMAIL', label: 'Email' }, + { value: 'FAX', label: 'Fax' }, + { value: 'LETTER', label: 'Letter' }, + { value: 'PHONE', label: 'Phone (recorded line)' } +]; + +export function confirmationModeLabel(mode?: string | null): string { + return CONFIRMATION_MODES.find((m) => m.value === mode)?.label ?? mode ?? ''; +} + +/** The counterparty's confirmation the back office records when it settles a deal (both optional). */ +export interface SettleConfirmation { + /** DD-MM-YYYY. */ + receivedOn?: string; + ref?: string; +} + +function settleConfirmationBody(c: SettleConfirmation): Record { + const body: Record = {}; + if (c.receivedOn) { + body['confirmationReceivedOn'] = c.receivedOn; + body['dateFormat'] = 'dd-MM-yyyy'; + body['locale'] = 'en'; + } + if (c.ref?.trim()) { + body['confirmationRef'] = c.ref.trim(); + } + return body; +} + +/** One deal slip: a purchase (BUY) or a sale / refund / redemption / switch, with who handled it. */ +export interface InvestmentDealSlipDto { + slipNo: string; + side: 'BUY' | 'SELL' | 'REFUND' | 'REDEEM' | 'SWITCH'; + status: 'PENDING_SETTLEMENT' | 'SETTLED' | 'CANCELLED'; + recordStatus: string; + purchaseId: number; + saleId?: number; + dealNo: string; + certNo?: string; + dealDate: string; + dealTime?: string; + settlementType?: string; + settlementDate: string; + partyCode?: string; + counterparty?: string; + bondType: string; + scriptCode: string; + scriptDesc: string; + isin?: string; + category?: string; + units: number; + faceValue: number; + price?: number; + consideration: number; + accruedInterest?: number; + brokerage?: number; + otherCharges?: number; + netAmount: number; + bankGlCode?: string; + bankGlName?: string; + dealMode: 'DIRECT' | 'BROKER'; + brokerCode?: string; + tradeNo?: string; + orderNo?: string; + confirmationMode?: ConfirmationMode; + confirmationReceivedOn?: string; + confirmationRef?: string; + enteredBy?: string; + enteredOn?: string; + approvedBy?: string; + approvedOn?: string; + settledBy?: string; + settledOn?: string; + settleApprovedBy?: string; + settleApprovedOn?: string; + cancelledBy?: string; + cancelledOn?: string; + voucherNo?: string; + glTransactionId?: string; + remarks?: string; +} + /** A market valuation (MTM) of AFS or HFT holdings: a preview (no id) or a posted run. */ export interface InvestmentValuationDto { id?: number; diff --git a/src/app/investment/share-investment/share-investment.component.html b/src/app/investment/share-investment/share-investment.component.html index f2936fde7..16e5c8627 100644 --- a/src/app/investment/share-investment/share-investment.component.html +++ b/src/app/investment/share-investment/share-investment.component.html @@ -72,6 +72,36 @@ @if (activeTab === 'holdings') {
+ @if (settleTarget?.kind === 'purchase') { + +
+ + +
+

+ Record the institution's confirmation (share certificate / letter) if it has come; leave it blank otherwise + and the Deal Slip Register shows it as awaited. +

+
+ + +
+ } @@ -113,8 +143,8 @@ @@ -127,6 +157,7 @@ Drop } + } @empty { @@ -219,6 +250,15 @@ + @if (shareLimit; as l) { @@ -415,6 +455,15 @@ + @if (refundQuote; as q) {
@@ -448,10 +497,40 @@ + @if (settleTarget?.kind === 'sale') { + +
+ + +
+

+ Record the institution's confirmation (share certificate / letter) if it has come; leave it blank otherwise + and the Deal Slip Register shows it as awaited. +

+
+ + +
+ }
- + @@ -464,7 +543,10 @@ @for (s of pendingRefunds; track s.id) { @@ -479,8 +561,8 @@ @@ -492,6 +574,7 @@ > Drop + } @empty { diff --git a/src/app/investment/share-investment/share-investment.component.ts b/src/app/investment/share-investment/share-investment.component.ts index 7e9404b31..5de6cd708 100644 --- a/src/app/investment/share-investment/share-investment.component.ts +++ b/src/app/investment/share-investment/share-investment.component.ts @@ -13,6 +13,7 @@ import { forkJoin } from 'rxjs'; import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component'; import { InvestmentTxn, SETTLEMENT_BANK_GL_OPTIONS, todayDDMMYYYY } from '../investment-data'; import { exportWorkbook } from '../investment-export'; +import { saveDealSlipPdf } from '../deal-slip-pdf'; import { InvestmentDividendDto, InvestmentPartyDto, @@ -22,6 +23,8 @@ import { InvestmentService, InvestmentShareLimitDto, SHARE_INSTITUTION_TYPES, + CONFIRMATION_MODES, + SettleConfirmation, apiErrorMessage, awaitingApproval, sentForApproval, @@ -42,6 +45,8 @@ interface SharePurchaseForm { shares: number | null; pricePerShare: number | null; bankGlCode: string; + /** How the deal was confirmed to the institution (deal slip). */ + confirmationMode: string; } interface ShareRefundForm { @@ -51,6 +56,7 @@ interface ShareRefundForm { pricePerShare: number | null; bankGlCode: string; remarks: string; + confirmationMode: string; } interface ShareDividendForm { @@ -218,18 +224,68 @@ export class ShareInvestmentComponent implements OnInit { return `inv-badge inv-badge--${status.toLowerCase()}`; } + // -------------------- Settle with the institution's confirmation (back office) -------------------- + /** The deal or refund the back office is settling, with the confirmation it records (both optional). */ + settleTarget: { kind: 'purchase' | 'sale'; id: number; label: string } | null = null; + settleConfirmation: SettleConfirmation = {}; + readonly confirmationModes = CONFIRMATION_MODES; + + openPurchaseSettle(d: InvestmentPurchaseDto): void { + this.settleTarget = { kind: 'purchase', id: d.id, label: `deal ${d.dealNo}` }; + this.settleConfirmation = {}; + } + + openRefundSettle(s: InvestmentSaleDto): void { + if (!s.id) { + return; + } + this.settleTarget = { kind: 'sale', id: s.id, label: `refund ${s.saleNo ?? ''} on deal ${s.dealNo}` }; + this.settleConfirmation = {}; + } + + confirmSettle(): void { + const t = this.settleTarget; + if (!t) { + return; + } + if (t.kind === 'purchase') { + const d = this.deals.find((x) => x.id === t.id); + if (d) { + this.settleDeal(d, this.settleConfirmation); + } + } else { + const s = this.sales.find((x) => x.id === t.id); + if (s) { + this.settleRefund(s, this.settleConfirmation); + } + } + } + + /** Downloads the deal slip (PDF) of a purchase deal number or a refund's 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)) + }); + } + /** The purchase being settled; its button stays disabled so it cannot be sent twice. */ settlingDealId: number | null = null; /** Posts a pending deal to GL dated its settlement date: Dr 100706 Investment in Shares / Cr bank. */ - settleDeal(d: InvestmentPurchaseDto): void { + settleDeal(d: InvestmentPurchaseDto, confirmation: SettleConfirmation = {}): void { if (this.settlingDealId !== null) { return; } this.settlingDealId = d.id; - this.investmentService.settlePurchase(d.id).subscribe({ + this.investmentService.settlePurchase(d.id, confirmation).subscribe({ next: (res) => { this.settlingDealId = null; + this.settleTarget = null; this.toast( awaitingApproval(res) ? sentForApproval(`Settlement of deal ${d.dealNo}`) @@ -271,7 +327,8 @@ export class ShareInvestmentComponent implements OnInit { settlementType: 'T+0', shares: null, pricePerShare: null, - bankGlCode: SETTLEMENT_BANK_GL_OPTIONS[0].glCode + bankGlCode: SETTLEMENT_BANK_GL_OPTIONS[0].glCode, + confirmationMode: '' }; } @@ -386,6 +443,7 @@ export class ShareInvestmentComponent implements OnInit { category: SHARE_CATEGORY, crGlAccountId, subType: '', + ...(f.confirmationMode ? { confirmationMode: f.confirmationMode } : {}), dateFormat: 'dd-MM-yyyy', locale: 'en' }; @@ -474,7 +532,8 @@ export class ShareInvestmentComponent implements OnInit { units: null, pricePerShare: null, bankGlCode: '', - remarks: '' + remarks: '', + confirmationMode: '' }; } @@ -546,6 +605,7 @@ export class ShareInvestmentComponent implements OnInit { saleRate: f.pricePerShare, bankGlAccountId, ...(f.remarks.trim() ? { remarks: f.remarks.trim() } : {}), + ...(f.confirmationMode ? { confirmationMode: f.confirmationMode } : {}), dateFormat: 'dd-MM-yyyy', locale: 'en' }; @@ -570,14 +630,15 @@ export class ShareInvestmentComponent implements OnInit { /** The pending refund being settled or dropped; its buttons stay disabled so it cannot be sent twice. */ busySaleId: number | null = null; - settleRefund(s: InvestmentSaleDto): void { + settleRefund(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; this.toast( awaitingApproval(res) ? sentForApproval(`Settlement of the refund on deal ${s.dealNo}`)
Deal NoSale No / Deal No Institution Shares Amount
- {{ s.dealNo }} + {{ s.saleNo || s.dealNo }} + @if (s.saleNo) { +
Deal {{ s.dealNo }} + }
{{ s.partyName }} {{ s.units | number: '1.0-0' }}