feat: add bond and share investment management modules with report and deal slip generation

This commit is contained in:
2026-10-29 13:55:43 +05:30
parent b3f2addb5c
commit 3c09fb5dba
8 changed files with 1106 additions and 51 deletions
@@ -128,6 +128,34 @@
@if (activeTab === 'holdings') {
<div class="pg-card">
<div class="pos-section-label">{{ instrumentLabel(bondType) }} Holdings Register</div>
@if (settleTarget?.kind === 'purchase') {
<div class="pos-section-label">Settle {{ settleTarget?.label }} — counterparty confirmation (back office)</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Confirmation received on</span
><mifosx-date-ddmmyyyy [(ngModel)]="settleConfirmation.receivedOn"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Confirmation / contract note ref.</span
><input type="text" maxlength="100" [(ngModel)]="settleConfirmation.ref"
/></label>
</div>
<p class="pg-sub" style="padding: 0 16px">
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.
</p>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="settlingDealId !== null"
(click)="confirmSettle()"
>
{{ settlingDealId !== null ? 'Settling…' : 'Settle and post to GL' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="settleTarget = null">Cancel</button>
</div>
}
<table class="pg-table">
<thead>
<tr>
@@ -173,14 +201,19 @@
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="settlingDealId !== null"
(click)="settleDeal(h)"
[disabled]="settlingDealId !== null || settleTarget !== null"
(click)="openPurchaseSettle(h)"
>
{{ settlingDealId === h.id ? 'Settling…' : 'Settle (' + h.settlementDate + ')' }}
</button>
} @else if (h.glTransactionId) {
<span class="pg-chip" title="Journal entry transaction">GL {{ h.glTransactionId }}</span>
}
@if (h.dealNo) {
<button type="button" class="pg-btn pg-btn--ghost" (click)="printDealSlip(h.dealNo)">
Deal Slip
</button>
}
</td>
</tr>
} @empty {
@@ -238,6 +271,15 @@
><span>* Deal Time (HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
/></label>
<label class="inv-form-field">
<span>Confirmation to Counterparty</span>
<select [(ngModel)]="purchaseForm.confirmationMode">
<option value="">-- Select --</option>
@for (m of confirmationModes; track m.value) {
<option [value]="m.value">{{ m.label }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* Settlement Type</span>
<select [(ngModel)]="purchaseForm.settlementType" (ngModelChange)="onSettlementTypeChange()">
@@ -1092,6 +1134,24 @@
><span>Brokerage</span
><input type="number" min="0" [(ngModel)]="sale.brokerage" (change)="refreshSaleQuote()"
/></label>
<label class="inv-form-field">
<span>Broker</span>
<select [(ngModel)]="sale.brokerCode">
<option value="">-- Direct deal --</option>
@for (b of brokerOptions; track b.brokerCode) {
<option [value]="b.brokerCode">{{ b.brokerCode }} — {{ b.brokerName }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Confirmation to Counterparty</span>
<select [(ngModel)]="sale.confirmationMode">
<option value="">-- Select --</option>
@for (m of confirmationModes; track m.value) {
<option [value]="m.value">{{ m.label }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* Amount received into (Bank)</span>
<select [(ngModel)]="sale.bankGlCode" (ngModelChange)="refreshSaleQuote()">
@@ -1185,10 +1245,38 @@
</div>
<div class="pos-section-label">Pending Sales — settle on or after the settlement date</div>
@if (settleTarget?.kind === 'sale') {
<div class="pos-section-label">Settle {{ settleTarget?.label }} — counterparty confirmation (back office)</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Confirmation received on</span
><mifosx-date-ddmmyyyy [(ngModel)]="settleConfirmation.receivedOn"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Confirmation / contract note ref.</span
><input type="text" maxlength="100" [(ngModel)]="settleConfirmation.ref"
/></label>
</div>
<p class="pg-sub" style="padding: 0 16px">
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.
</p>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="busySaleId !== null"
(click)="confirmSettle()"
>
{{ busySaleId !== null ? 'Settling…' : 'Settle and post to GL' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="settleTarget = null">Cancel</button>
</div>
}
<table class="pg-table">
<thead>
<tr>
<th>Deal No</th>
<th>Sale No / Deal No</th>
<th>Script</th>
<th>Buyer</th>
<th class="num">Units</th>
@@ -1203,7 +1291,10 @@
@for (s of pendingSalesForType; track s.id) {
<tr>
<td>
<span class="pg-chip">{{ s.dealNo }}</span>
<span class="pg-chip">{{ s.saleNo || s.dealNo }}</span>
@if (s.saleNo) {
<br /><small>Deal {{ s.dealNo }}</small>
}
</td>
<td>{{ s.scriptCode }} — {{ s.scriptDesc }}</td>
<td>{{ s.partyName }}</td>
@@ -1216,8 +1307,8 @@
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="busySaleId !== null"
(click)="settleSale(s)"
[disabled]="busySaleId !== null || settleTarget !== null"
(click)="openSaleSettle(s)"
>
{{ busySaleId === s.id ? 'Settling…' : 'Settle' }}
</button>
@@ -1229,6 +1320,7 @@
>
Drop
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="printDealSlip(s.saleNo)">Deal Slip</button>
</td>
</tr>
} @empty {
@@ -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;
+359
View File
@@ -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<string, string> = {
BUY: 'PURCHASE',
SELL: 'SALE',
REFUND: 'SHARE REFUND',
REDEEM: 'REDEMPTION',
SWITCH: 'SWITCH OUT'
};
const STATUS_LABEL: Record<string, string> = {
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-<slip no>.pdf. */
export function saveDealSlipPdf(slip: InvestmentDealSlipDto): void {
buildDealSlipPdf(slip, brokerNameOf(slip.brokerCode)).save(`deal-slip-${slip.slipNo}.pdf`);
}
@@ -264,8 +264,7 @@
activeReport === 'fdLedger' ||
activeReport === 'bondLedger' ||
activeReport === 'shareLedger' ||
activeReport === 'amortizeLedger' ||
activeReport === 'dealSlip'
activeReport === 'amortizeLedger'
) {
<div class="pg-card">
<div class="pos-section-label">
@@ -276,9 +275,7 @@
? 'Govt/Bond Ledger'
: activeReport === 'shareLedger'
? 'Share Ledger'
: activeReport === 'amortizeLedger'
? 'Amortize Ledger'
: 'Deal Slip Register'
: 'Amortize Ledger'
}}
</div>
<table class="pg-table">
@@ -300,9 +297,7 @@
? bondTxns
: activeReport === 'shareLedger'
? shareTxns
: activeReport === 'amortizeLedger'
? amortizeTxns
: dealSlips;
: amortizeTxns;
track $index
) {
<tr>
@@ -324,6 +319,122 @@
</table>
</div>
}
@if (activeReport === 'dealSlip') {
<div class="pg-card">
<div class="pos-section-label">Deal Slip Register — every deal serially numbered, cancelled ones included</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>From (deal date)</span><mifosx-date-ddmmyyyy [(ngModel)]="slipFrom"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To (deal date)</span><mifosx-date-ddmmyyyy [(ngModel)]="slipTo"></mifosx-date-ddmmyyyy
></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" [disabled]="slipLoading" (click)="loadDealSlips()">
{{ slipLoading ? 'Loading…' : 'Show' }}
</button>
</div>
@if (slipError) {
<p class="inv-hint inv-hint--bad">{{ slipError }}</p>
}
@if (slipGaps.length) {
<p class="inv-hint inv-hint--bad">
Missing slip numbers in this period: {{ slipGaps.join(', ') }}. Every number must be accounted for.
</p>
} @else if (slips.length) {
<p class="inv-hint">No gaps: every slip number in this period is accounted for.</p>
}
@if (slipsAwaited) {
<p class="inv-hint inv-hint--bad">
Counterparty confirmation still awaited on {{ slipsAwaited }} deal{{ slipsAwaited === 1 ? '' : 's' }}
(marked below).
</p>
}
<table class="pg-table">
<thead>
<tr>
<th>Slip No</th>
<th>Type</th>
<th>Deal Date / Time</th>
<th>Counterparty</th>
<th>Security</th>
<th class="num">Units</th>
<th class="num">Price</th>
<th class="num">Net Amount</th>
<th>Broker</th>
<th>Confirmation</th>
<th>Entered / Approved</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@for (s of slips; track s.slipNo) {
<tr [style.opacity]="s.status === 'CANCELLED' ? 0.6 : 1">
<td>
<span class="pg-chip">{{ s.slipNo }}</span>
@if (s.side !== 'BUY') {
<br /><small>Deal {{ s.dealNo }}</small>
}
</td>
<td>{{ s.side }}</td>
<td>
{{ s.dealDate }}<br /><small
>{{ s.dealTime || '—' }} · {{ s.settlementType }} {{ s.settlementDate }}</small
>
</td>
<td>{{ s.counterparty || '—' }}</td>
<td>
{{ s.scriptCode }} {{ s.scriptDesc }}
@if (s.isin) {
<br /><small>{{ s.isin }}</small>
}
</td>
<td class="num">{{ s.units | number: '1.0-6' }}</td>
<td class="num">{{ s.price | number: '1.2-4' }}</td>
<td class="num">₹{{ s.netAmount | number: '1.2-2' }}</td>
<td>
@if (s.dealMode === 'BROKER') {
{{ s.brokerCode }}<br /><small
>{{ brokerName(s.brokerCode) }} · ₹{{ s.brokerage | number: '1.2-2' }}</small
>
} @else {
Direct
}
</td>
<td>
{{ confirmationLabel(s.confirmationMode) || '—' }}<br />
@if (s.confirmationReceivedOn) {
<small
>Received {{ s.confirmationReceivedOn
}}{{ s.confirmationRef ? ' · ' + s.confirmationRef : '' }}</small
>
} @else if (confirmationAwaited(s)) {
<small class="inv-hint--bad">Awaited</small>
}
</td>
<td>
{{ s.enteredBy || '—' }}<br /><small>{{ s.approvedBy ? 'Approved: ' + s.approvedBy : '' }}</small>
</td>
<td>
<span [class]="'inv-badge inv-badge--' + s.status.toLowerCase()">{{
s.status === 'PENDING_SETTLEMENT' ? 'PENDING' : s.status
}}</span>
</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="printSlip(s)">PDF</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="13" class="pg-empty">{{ slipLoading ? 'Loading…' : 'No deals in this period.' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'intDueRegister') {
<div class="pg-card">
<div class="pos-section-label">
@@ -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<string, { width: number; nos: number[] }>();
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',
+127 -6
View File
@@ -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<unknown> {
@@ -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<InvestmentDealSlipDto[]> {
return this.http.get<InvestmentDealSlipDto[]>('/investment/deal-slips', { params: { fromDate, toDate } });
}
/** One deal slip by its number: a purchase deal number or a sale number. */
getDealSlip(slipNo: string): Observable<InvestmentDealSlipDto[]> {
return this.http.get<InvestmentDealSlipDto[]>('/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<InvestmentNonSlrLimitDto> {
const params: Record<string, string> = { 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<string, string> {
const body: Record<string, string> = {};
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;
@@ -72,6 +72,36 @@
@if (activeTab === 'holdings') {
<div class="pg-card">
<div class="pos-section-label">Share Holdings Register</div>
@if (settleTarget?.kind === 'purchase') {
<div class="pos-section-label">
Settle {{ settleTarget?.label }} — confirmation from the institution (back office)
</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Confirmation received on</span
><mifosx-date-ddmmyyyy [(ngModel)]="settleConfirmation.receivedOn"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Confirmation / letter ref.</span
><input type="text" maxlength="100" [(ngModel)]="settleConfirmation.ref"
/></label>
</div>
<p class="inv-hint">
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.
</p>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="settlingDealId !== null"
(click)="confirmSettle()"
>
{{ settlingDealId !== null ? 'Settling…' : 'Settle and post to GL' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="settleTarget = null">Cancel</button>
</div>
}
<table class="pg-table">
<thead>
<tr>
@@ -113,8 +143,8 @@
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="settlingDealId !== null"
(click)="settleDeal(d)"
[disabled]="settlingDealId !== null || settleTarget !== null"
(click)="openPurchaseSettle(d)"
>
Settle
</button>
@@ -127,6 +157,7 @@
Drop
</button>
}
<button type="button" class="pg-btn pg-btn--ghost" (click)="printDealSlip(d.dealNo)">Deal Slip</button>
</td>
</tr>
} @empty {
@@ -219,6 +250,15 @@
<label class="inv-form-field"
><span>Category</span><input type="text" value="AFS (Available For Sale)" readonly
/></label>
<label class="inv-form-field">
<span>Confirmation to Institution</span>
<select [(ngModel)]="purchaseForm.confirmationMode">
<option value="">-- Select --</option>
@for (m of confirmationModes; track m.value) {
<option [value]="m.value">{{ m.label }}</option>
}
</select>
</label>
</div>
@if (shareLimit; as l) {
@@ -415,6 +455,15 @@
<label class="inv-form-field"
><span>Remarks</span><input type="text" maxlength="500" [(ngModel)]="refund.remarks"
/></label>
<label class="inv-form-field">
<span>Confirmation to Institution</span>
<select [(ngModel)]="refund.confirmationMode">
<option value="">-- Select --</option>
@for (m of confirmationModes; track m.value) {
<option [value]="m.value">{{ m.label }}</option>
}
</select>
</label>
</div>
@if (refundQuote; as q) {
<table class="pg-table">
@@ -448,10 +497,40 @@
</div>
<div class="pos-section-label">Pending Refunds (posted on Settle)</div>
@if (settleTarget?.kind === 'sale') {
<div class="pos-section-label">
Settle {{ settleTarget?.label }} — confirmation from the institution (back office)
</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Confirmation received on</span
><mifosx-date-ddmmyyyy [(ngModel)]="settleConfirmation.receivedOn"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Confirmation / letter ref.</span
><input type="text" maxlength="100" [(ngModel)]="settleConfirmation.ref"
/></label>
</div>
<p class="inv-hint">
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.
</p>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="busySaleId !== null"
(click)="confirmSettle()"
>
{{ busySaleId !== null ? 'Settling…' : 'Settle and post to GL' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="settleTarget = null">Cancel</button>
</div>
}
<table class="pg-table">
<thead>
<tr>
<th>Deal No</th>
<th>Sale No / Deal No</th>
<th>Institution</th>
<th class="num">Shares</th>
<th class="num">Amount</th>
@@ -464,7 +543,10 @@
@for (s of pendingRefunds; track s.id) {
<tr>
<td>
<span class="pg-chip">{{ s.dealNo }}</span>
<span class="pg-chip">{{ s.saleNo || s.dealNo }}</span>
@if (s.saleNo) {
<br /><small>Deal {{ s.dealNo }}</small>
}
</td>
<td>{{ s.partyName }}</td>
<td class="num">{{ s.units | number: '1.0-0' }}</td>
@@ -479,8 +561,8 @@
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="busySaleId !== null"
(click)="settleRefund(s)"
[disabled]="busySaleId !== null || settleTarget !== null"
(click)="openRefundSettle(s)"
>
Settle
</button>
@@ -492,6 +574,7 @@
>
Drop
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="printDealSlip(s.saleNo)">Deal Slip</button>
</td>
</tr>
} @empty {
@@ -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}`)