feat(investment): settle purchases from Holdings; broken-period interest to settlement date

- Purchases are saved as Pending Settlement. Holdings show the status and
  a Settle button (from the settlement date), and the GL transaction id
  once settled.
- The transaction log shows "Not posted (pending settlement)" until a
  deal is settled, and dates the purchase on its settlement date.
- Broken-period interest is counted up to the settlement date.
- Accounting Preview title and the Holdings Settlement Date column
  reflect settlement-date posting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 10:28:19 +05:30
co-authored by Claude Opus 5.5
parent 6a04a92113
commit c803e20628
4 changed files with 66 additions and 16 deletions
@@ -137,11 +137,12 @@
<th class="num">Units</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Purchase Date</th>
<th>Settlement Date</th>
<th>Maturity Date</th>
<th class="num col-in">Accrued Interest</th>
<th class="num col-net">Maturity Value</th>
<th>Status</th>
<th>Settlement</th>
</tr>
</thead>
<tbody>
@@ -160,12 +161,23 @@
<td class="num col-in">₹{{ accrued(h) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
<span [class]="statusClass(h)">{{
h.status === 'PENDING_SETTLEMENT' ? 'PENDING SETTLEMENT' : h.status
}}</span>
</td>
<td>
@if (h.status === 'PENDING_SETTLEMENT') {
<button type="button" class="pg-btn pg-btn--primary" (click)="settleDeal(h)">
Settle ({{ h.settlementDate }})
</button>
} @else if (h.glTransactionId) {
<span class="pg-chip" title="Journal entry transaction">GL {{ h.glTransactionId }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="11" class="pg-empty">No {{ instrumentLabel(bondType) }} holdings yet.</td>
<td colspan="12" class="pg-empty">No {{ instrumentLabel(bondType) }} holdings yet.</td>
</tr>
}
</tbody>
@@ -384,7 +396,7 @@
}
<!-- 3. Accounting preview -->
<div class="pos-section-label">Accounting Preview (posted after checker approval)</div>
<div class="pos-section-label">Accounting Preview (posted on Settle, dated the settlement date)</div>
@if (purchaseAccountingPreview.length) {
<table class="pg-table">
<thead>
@@ -1284,7 +1296,7 @@
<td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : t.glName || '—' }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
@@ -239,17 +239,19 @@ export class BondInvestmentComponent implements OnInit {
});
}
/** A purchase reaches the GL only when it is settled, dated the settlement date. */
private purchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn {
const posted = !!d.glTransactionId;
return {
certNo: d.certNo,
instrumentType: d.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${d.dealNo}`,
txnDate: d.dealDate,
txnDate: d.settlementDate,
amount: d.settlementAmount,
particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]`,
glCode: d.investmentGlCode,
glName: d.investmentGlName
glCode: posted ? d.investmentGlCode : '',
glName: posted ? `${d.investmentGlName} (txn ${d.glTransactionId})` : 'Not posted (pending settlement)'
};
}
@@ -573,20 +575,19 @@ export class BondInvestmentComponent implements OnInit {
return approxYtm(f.intRate, f.purchaseRate || 100, yearsBetween(f.issueDate, this.purchaseMaturityDate));
}
/** Accrued Interest at purchase — interest earned by the seller since the last coupon date. */
/** Interest owed to the seller from the last coupon date up to the deal date, on a 30/360 basis —
* interest is counted to the event date, not the date the money moves (Jitendra Sir). */
/** Broken-period interest owed to the seller from the last coupon date up to the settlement date, 30/360. */
get purchaseAccruedInterest(): number {
const f = this.purchaseForm;
if (!f.intRate || !f.lastIntDt || !f.dealDate) {
return 0;
}
return Math.round((this.purchaseBaseAmount * (f.intRate / 100) * days360(f.lastIntDt, f.dealDate)) / 360);
return Math.round((this.purchaseBaseAmount * (f.intRate / 100) * this.purchaseAccruedDays) / 360);
}
get purchaseAccruedDays(): number {
const f = this.purchaseForm;
return f.lastIntDt && f.dealDate ? days360(f.lastIntDt, f.dealDate) : 0;
// Up to the settlement date, when the bond leaves the seller (RBI para 83; Jitendra Sir).
return f.lastIntDt && this.purchaseSettlementDate ? days360(f.lastIntDt, this.purchaseSettlementDate) : 0;
}
/** Premium (+) or discount (−) paid over face value. */
@@ -842,7 +843,9 @@ export class BondInvestmentComponent implements OnInit {
};
this.investmentService.createPurchase(body).subscribe({
next: (res) => {
this.toast(`Deal ${res.resourceIdentifier} saved. GL posting will follow once accounting is approved.`);
this.toast(
`Deal ${res.resourceIdentifier} saved — pending settlement. Settle it from Holdings on or after the settlement date.`
);
this.activeTab = 'holdings';
this.loadPurchases();
},
@@ -941,6 +944,24 @@ export class BondInvestmentComponent implements OnInit {
this.toast(`Deal for Cert No ${h.certNo} retrieved.`);
}
/** Posts a pending deal to GL dated its settlement date; under maker-checker it waits in the Checker Inbox. */
settleDeal(h: InvestmentHolding): void {
if (!h.id) {
return;
}
this.investmentService.settlePurchase(h.id).subscribe({
next: (res) => {
this.toast(
res.rollbackTransaction
? `Settlement of deal ${h.dealNo} sent for approval (Checker Inbox).`
: `Deal ${h.dealNo} settled and posted to GL (transaction ${res.transactionId}).`
);
this.loadPurchases();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
dropPurchase(): void {
const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo);
if (!h?.id) {
+5 -1
View File
@@ -17,7 +17,7 @@
*/
export type InstrumentType = 'FD' | 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND' | 'SHARE';
export type HoldingStatus = 'ACTIVE' | 'MATURED' | 'SOLD' | 'WRITTEN_OFF';
export type HoldingStatus = 'PENDING_SETTLEMENT' | 'ACTIVE' | 'MATURED' | 'SOLD' | 'WRITTEN_OFF';
export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' | 'CUMULATIVE';
export type TxnType =
| 'PURCHASE'
@@ -133,6 +133,10 @@ export interface InvestmentHolding {
partyCode?: string;
bookValue?: number;
settlementAmount?: number;
/** Settlement date of the deal (deal date + T+n) — it can be settled on or after this day. */
settlementDate?: string;
/** Journal entry transaction posted when the deal was settled. */
glTransactionId?: string;
}
export interface InvestmentTxn {
+14 -1
View File
@@ -12,6 +12,7 @@ import { Observable, forkJoin } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import {
DailyScriptRate,
HoldingStatus,
InstrumentType,
InvestmentHolding,
PARTY_MASTER,
@@ -119,6 +120,8 @@ export interface InvestmentPurchaseDto {
crGlName: string;
otherDetails?: string;
status: string;
glTransactionId?: string;
settledOnDate?: string;
}
export interface InvestmentGlAccount {
@@ -200,6 +203,14 @@ 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 }> {
return this.http.post<{ transactionId?: string; rollbackTransaction?: boolean }>(
`/investment/purchases/${purchaseId}/settle`,
{}
);
}
createPurchase(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/purchases', body);
}
@@ -316,7 +327,9 @@ export function toHolding(p: InvestmentPurchaseDto): InvestmentHolding {
glName: p.investmentGlName,
crToAc: p.crGlCode,
brokerCode: '',
status: 'ACTIVE',
status: p.status as HoldingStatus,
settlementDate: p.settlementDate,
glTransactionId: p.glTransactionId,
category: p.category,
bookValue: p.bookValue,
settlementAmount: p.settlementAmount