feat(investment): deal time, interest cycle and charges on Purchase Entry

As per Jitendra Sir's review:
- Deal Time is now a mandatory field next to Deal Date.
- New Interest Cycle section: the two coupon days, last and next coupon,
  next coupon amount and days to it.
- Stamp duty, brokerage and other charges sit on the main form. They are
  expenses on their own GLs (500601/500602/500603), so they are added to
  the settlement amount but no longer to the book value, and each shows
  as its own debit line in the accounting preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 11:54:33 +05:30
co-authored by Claude Opus 5.5
parent 88c4242692
commit 88f34f58e3
4 changed files with 140 additions and 17 deletions
@@ -214,6 +214,10 @@
(ngModelChange)="onDealDateChange()" (ngModelChange)="onDealDateChange()"
></mifosx-date-ddmmyyyy ></mifosx-date-ddmmyyyy
></label> ></label>
<label class="inv-form-field"
><span>* Deal Time (HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
/></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Settlement Type</span> <span>* Settlement Type</span>
<select [(ngModel)]="purchaseForm.settlementType"> <select [(ngModel)]="purchaseForm.settlementType">
@@ -261,6 +265,15 @@
} }
</select> </select>
</label> </label>
<label class="inv-form-field"
><span>Stamp Duty (₹)</span><input type="number" [(ngModel)]="purchaseForm.stampDuty" min="0"
/></label>
<label class="inv-form-field"
><span>Brokerage (₹)</span><input type="number" [(ngModel)]="purchaseForm.brokerage" min="0"
/></label>
<label class="inv-form-field"
><span>Other Charges (₹)</span><input type="number" [(ngModel)]="purchaseForm.otherCharges" min="0"
/></label>
</div> </div>
<!-- 2. Calculated — filled by the system, read-only --> <!-- 2. Calculated — filled by the system, read-only -->
@@ -316,12 +329,38 @@
/></label> /></label>
<label class="inv-form-field"><span>YTM %</span><input type="text" [value]="purchaseYtm" readonly /></label> <label class="inv-form-field"><span>YTM %</span><input type="text" [value]="purchaseYtm" readonly /></label>
} }
<label class="inv-form-field"
><span>Charges (expense)</span
><input type="text" [value]="'₹' + (purchaseTotalCharges | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Total Settlement Amount</span ><span>Total Settlement Amount</span
><input type="text" [value]="'₹' + (purchaseSettlementAmount | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (purchaseSettlementAmount | number: '1.2-2')" readonly
/></label> /></label>
</div> </div>
@if (!isMutualFund && purchaseCouponDates) {
<div class="pos-section-label">Interest Cycle</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Interest Dates (half-yearly)</span><input type="text" [value]="purchaseCouponDates" readonly
/></label>
<label class="inv-form-field"
><span>Last Coupon</span><input type="text" [value]="purchaseForm.lastIntDt || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Next Coupon</span><input type="text" [value]="purchaseNextCouponDate || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Next Coupon Amount</span
><input type="text" [value]="'₹' + (purchaseNextCouponAmount | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Days to Next Coupon</span><input type="text" [value]="purchaseDaysToNextCoupon" readonly
/></label>
</div>
}
<!-- 3. Accounting preview --> <!-- 3. Accounting preview -->
<div class="pos-section-label">Accounting Preview (posted after checker approval)</div> <div class="pos-section-label">Accounting Preview (posted after checker approval)</div>
@if (purchaseAccountingPreview.length) { @if (purchaseAccountingPreview.length) {
@@ -358,10 +397,6 @@
@if (showMoreDetails) { @if (showMoreDetails) {
<div class="pos-section-label">More Details (optional)</div> <div class="pos-section-label">More Details (optional)</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"
><span>Deal Time (HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
/></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Sub Type</span> <span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType"> <select [(ngModel)]="purchaseForm.subType">
@@ -374,9 +409,6 @@
<label class="inv-form-field" <label class="inv-form-field"
><span>Trade Amt</span><input type="number" [(ngModel)]="purchaseForm.tradeAmt" min="0" ><span>Trade Amt</span><input type="number" [(ngModel)]="purchaseForm.tradeAmt" min="0"
/></label> /></label>
<label class="inv-form-field"
><span>Stamp Duty</span><input type="number" [(ngModel)]="purchaseForm.stampDuty" min="0"
/></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Stamp Duty A/c</span><input type="text" [(ngModel)]="purchaseForm.stampDutyAc" ><span>Stamp Duty A/c</span><input type="text" [(ngModel)]="purchaseForm.stampDutyAc"
/></label> /></label>
@@ -413,9 +445,6 @@
><span>TBill GL A/c</span><input type="text" [(ngModel)]="purchaseForm.tbillGlAc" ><span>TBill GL A/c</span><input type="text" [(ngModel)]="purchaseForm.tbillGlAc"
/></label> /></label>
} }
<label class="inv-form-field"
><span>Brokerage (₹)</span><input type="number" [(ngModel)]="purchaseForm.brokerage" min="0"
/></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Broker</span> <span>Broker</span>
<select [(ngModel)]="purchaseForm.brokerName"> <select [(ngModel)]="purchaseForm.brokerName">
@@ -29,11 +29,16 @@ import {
INVESTMENT_GL_BY_TYPE, INVESTMENT_GL_BY_TYPE,
INVESTMENT_GL_LOOKUP, INVESTMENT_GL_LOOKUP,
INTEREST_RECEIVABLE_GL, INTEREST_RECEIVABLE_GL,
STAMP_DUTY_GL,
BROKERAGE_GL,
OTHER_CHARGES_GL,
SETTLEMENT_BANK_GL_OPTIONS, SETTLEMENT_BANK_GL_OPTIONS,
periodBetween, periodBetween,
parseDDMMYYYY, parseDDMMYYYY,
days360, days360,
lastHalfYearlyCouponDate, lastHalfYearlyCouponDate,
nextHalfYearlyCouponDate,
couponDatesLabel,
InvestmentHolding, InvestmentHolding,
InvestmentTxn, InvestmentTxn,
InstrumentType, InstrumentType,
@@ -135,6 +140,7 @@ interface BondPurchaseForm {
holdingDays: number | null; holdingDays: number | null;
stampDuty: number | null; stampDuty: number | null;
stampDutyAc: string; stampDutyAc: string;
otherCharges: number | null;
} }
interface AccountingPreviewLine { interface AccountingPreviewLine {
@@ -448,7 +454,8 @@ export class BondInvestmentComponent implements OnInit {
nav: null, nav: null,
holdingDays: null, holdingDays: null,
stampDuty: null, stampDuty: null,
stampDutyAc: '' stampDutyAc: '',
otherCharges: null
}; };
} }
@@ -518,14 +525,41 @@ export class BondInvestmentComponent implements OnInit {
return this.isMutualFund ? this.purchaseForm.nav || 0 : 100; return this.isMutualFund ? this.purchaseForm.nav || 0 : 100;
} }
/** Book Value — what the investment is carried at, i.e. amount actually paid. */ /** Book Value — the price paid for the security. Charges are expenses and stay out of it (Jitendra Sir). */
get purchaseBookValue(): number { get purchaseBookValue(): number {
const f = this.purchaseForm; const f = this.purchaseForm;
if (this.isMutualFund) { if (this.isMutualFund) {
return this.purchaseBaseAmount + (f.stampDuty || 0) + (f.brokerage || 0); return this.purchaseBaseAmount;
} }
const rate = f.purchaseRate || 100; const rate = f.purchaseRate || 100;
return Math.round((this.purchaseBaseAmount * rate) / 100) + (f.brokerage || 0); return Math.round((this.purchaseBaseAmount * rate) / 100);
}
/** Stamp duty + brokerage + other charges — paid with the settlement, booked to expense GLs. */
get purchaseTotalCharges(): number {
const f = this.purchaseForm;
return (f.stampDuty || 0) + (f.brokerage || 0) + (f.otherCharges || 0);
}
// Interest cycle of the security being bought: its two coupon days and the next coupon after the deal.
get purchaseCouponDates(): string {
return this.purchaseMaturityDate ? couponDatesLabel(this.purchaseMaturityDate) : '';
}
get purchaseNextCouponDate(): string {
const f = this.purchaseForm;
return this.purchaseMaturityDate && f.dealDate
? nextHalfYearlyCouponDate(this.purchaseMaturityDate, f.dealDate)
: '';
}
get purchaseNextCouponAmount(): number {
const f = this.purchaseForm;
return f.intRate ? Math.round((this.purchaseBaseAmount * f.intRate) / 200) : 0;
}
get purchaseDaysToNextCoupon(): number {
return this.purchaseNextCouponDate ? days360(this.purchaseForm.dealDate, this.purchaseNextCouponDate) : 0;
} }
get purchaseYtm(): number { get purchaseYtm(): number {
@@ -574,9 +608,9 @@ export class BondInvestmentComponent implements OnInit {
} }
} }
/** Settlement Amt of Purchase — book value plus accrued interest payable to the seller. */ /** Settlement Amt of Purchase — book value, accrued interest payable to the seller and the charges. */
get purchaseSettlementAmount(): number { get purchaseSettlementAmount(): number {
return this.purchaseBookValue + this.purchaseAccruedInterest; return this.purchaseBookValue + this.purchaseAccruedInterest + this.purchaseTotalCharges;
} }
get purchaseMaturityAmount(): number { get purchaseMaturityAmount(): number {
@@ -683,6 +717,28 @@ export class BondInvestmentComponent implements OnInit {
amount: accrued amount: accrued
}); });
} }
const f = this.purchaseForm;
for (const [
gl,
amount
] of [
[
STAMP_DUTY_GL,
f.stampDuty
],
[
BROKERAGE_GL,
f.brokerage
],
[
OTHER_CHARGES_GL,
f.otherCharges
]
] as const) {
if (amount && amount > 0) {
lines.push({ side: 'Dr', glCode: gl.glCode, glName: gl.glName, amount });
}
}
lines.push({ side: 'Cr', glCode: crGl, glName: this.glNameOf(crGl), amount: total }); lines.push({ side: 'Cr', glCode: crGl, glName: this.glNameOf(crGl), amount: total });
return lines; return lines;
} }
@@ -693,6 +749,7 @@ export class BondInvestmentComponent implements OnInit {
if (!f.scriptCode) missing.push('Script Code'); if (!f.scriptCode) missing.push('Script Code');
if (!f.partyCode) missing.push('Party Code'); if (!f.partyCode) missing.push('Party Code');
if (!f.dealDate) missing.push('Deal Date'); if (!f.dealDate) missing.push('Deal Date');
if (!f.dealTime) missing.push('Deal Time');
if (this.isMutualFund) { if (this.isMutualFund) {
if (!f.considerAmt) missing.push('Consider Amt'); if (!f.considerAmt) missing.push('Consider Amt');
if (!f.nav) missing.push('NAV'); if (!f.nav) missing.push('NAV');
@@ -726,9 +783,11 @@ export class BondInvestmentComponent implements OnInit {
dealTime: f.dealTime || '', dealTime: f.dealTime || '',
settlementType: f.settlementType, settlementType: f.settlementType,
...(this.isMutualFund ...(this.isMutualFund
? { considerAmt: f.considerAmt, nav: f.nav, stampDuty: f.stampDuty ?? null } ? { considerAmt: f.considerAmt, nav: f.nav }
: { faceValue: f.faceValue, purchaseRate: f.purchaseRate, lastIntDate: f.lastIntDt }), : { faceValue: f.faceValue, purchaseRate: f.purchaseRate, lastIntDate: f.lastIntDt }),
stampDuty: f.stampDuty ?? null,
brokerage: f.brokerage ?? null, brokerage: f.brokerage ?? null,
otherCharges: f.otherCharges ?? null,
category: f.category, category: f.category,
crGlAccountId, crGlAccountId,
subType: f.subType || '', subType: f.subType || '',
+34
View File
@@ -303,6 +303,35 @@ export function lastHalfYearlyCouponDate(maturityStr: string, onStr: string): st
return best ? formatDDMMYYYY(best) : ''; return best ? formatDDMMYYYY(best) : '';
} }
/** First half-yearly coupon date after `onStr` (coupons fall on the maturity day/month and six months
* either side), never later than maturity. */
export function nextHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
const last = lastHalfYearlyCouponDate(maturityStr, onStr);
const mat = parseDDMMYYYY(maturityStr);
if (!last || isNaN(mat.getTime())) {
return '';
}
const l = parseDDMMYYYY(last);
const year = l.getFullYear() + (l.getMonth() >= 6 ? 1 : 0);
const month = (l.getMonth() + 6) % 12;
const next = new Date(year, month, Math.min(mat.getDate(), new Date(year, month + 1, 0).getDate()));
return formatDDMMYYYY(next > mat ? mat : next);
}
/** The two coupon days of a half-yearly security, e.g. "14 Feb / 14 Aug". */
export function couponDatesLabel(maturityStr: string): string {
const mat = parseDDMMYYYY(maturityStr);
if (isNaN(mat.getTime())) {
return '';
}
const label = (month: number): string => {
const day = Math.min(mat.getDate(), new Date(2001, month + 1, 0).getDate());
return `${day} ${new Date(2001, month, 1).toLocaleString('en-US', { month: 'short' })}`;
};
const first = mat.getMonth() % 6;
return `${label(first)} / ${label(first + 6)}`;
}
export function formatDDMMYYYY(d: Date): string { export function formatDDMMYYYY(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
} }
@@ -1480,6 +1509,11 @@ export const INVESTMENT_GL_BY_TYPE: Record<InstrumentType, string> = {
SHARE: '100706' SHARE: '100706'
}; };
/** Charges on an investment deal — expenses on their own GLs, not part of the book value (Jitendra Sir). */
export const STAMP_DUTY_GL: InvestmentGlOption = { glCode: '500601', glName: 'STAMP DUTY ON INVESTMENTS' };
export const BROKERAGE_GL: InvestmentGlOption = { glCode: '500602', glName: 'BROKERAGE ON INVESTMENTS' };
export const OTHER_CHARGES_GL: InvestmentGlOption = { glCode: '500603', glName: 'OTHER CHARGES ON INVESTMENTS' };
/** Interest receivable (the senior's "Provision A/c") — debited for interest bought from the seller. */ /** Interest receivable (the senior's "Provision A/c") — debited for interest bought from the seller. */
export const INTEREST_RECEIVABLE_GL: InvestmentGlOption = { export const INTEREST_RECEIVABLE_GL: InvestmentGlOption = {
glCode: '100603', glCode: '100603',
+1
View File
@@ -97,6 +97,7 @@ export interface InvestmentPurchaseDto {
balanceUnits: number; balanceUnits: number;
brokerage?: number; brokerage?: number;
stampDuty?: number; stampDuty?: number;
otherCharges?: number;
bookValue: number; bookValue: number;
premiumDiscount?: number; premiumDiscount?: number;
intRate?: number; intRate?: number;