feat(investment): Interest Entry screen with coupon and provision schedule

Replace the dummy interest calculation tab with the real Interest Entry:
pick Type / Script / Deal No and Retrieve the holding's schedule from
the backend - every half-yearly coupon and every 31 Mar / 30 Sep closing
provision up to maturity, with days (30/360), gross interest, amount
already booked, net income and the Dr/Cr entry per line. Display only;
nothing is posted to GL yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 18:36:01 +05:30
co-authored by Claude Opus 5.5
parent a4e0a22205
commit ca01e9b65b
3 changed files with 231 additions and 135 deletions
@@ -626,20 +626,14 @@
</div> </div>
} }
<!-- ===================== 03 INTEREST ENTRY / CALCULATION ===================== --> <!-- ===================== 03 INTEREST ENTRY ===================== -->
@if (activeTab === 'interest') { @if (activeTab === 'interest') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Investment - Govt./Bond Interest calculation</div> <div class="pos-section-label">Interest Entry — coupon and 31 Mar / 30 Sep provision schedule</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Type</span> <span>Type</span>
<select [(ngModel)]="intCalcType" (ngModelChange)="onIntCalcTypeChange()"> <select [(ngModel)]="intType" (ngModelChange)="onIntFilterChange()">
<option value="">-- All Types --</option> <option value="">-- All Types --</option>
@for (o of bondTypeOptions; track o.value) { @for (o of bondTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option> <option [value]="o.value">{{ o.label }}</option>
@@ -648,67 +642,149 @@
</label> </label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Script</span> <span>Script</span>
<select [(ngModel)]="intCalcScript"> <select [(ngModel)]="intScript" (ngModelChange)="onIntFilterChange()">
<option value="">-- All Scripts --</option> <option value="">-- All Scripts --</option>
@for (s of scriptOptions; track s.scriptCode) { @for (s of intScriptOptions; track s.scriptCode) {
<option [value]="s.scriptCode">{{ s.scriptCode }} — {{ s.scriptName }}</option> <option [value]="s.scriptCode">{{ s.scriptCode }} — {{ s.scriptName }}</option>
} }
</select> </select>
</label> </label>
<label class="inv-form-field"><span>Deal No</span><input type="text" [(ngModel)]="intCalcDealNo" /></label>
<label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="intCalcAll" style="height: 18px; width: 18px"
/></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Post To (Interest Income GL)</span> <span>* Deal No</span>
<select [(ngModel)]="intCalcGlCode"> <select [(ngModel)]="intPurchaseId" (ngModelChange)="intSchedule = null">
@for (g of interestIncomeGlOptions; track g.glCode) { <option [ngValue]="null">-- Select --</option>
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option> @for (h of intDealOptions; track h.id) {
<option [ngValue]="h.id">{{ h.dealNo }} — {{ h.scriptCode }} (Cert {{ h.certNo }})</option>
} }
</select> </select>
</label> </label>
<label class="inv-form-field"
><span>As On Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intAsOnDate"></mifosx-date-ddmmyyyy
></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processInterest()">Process</button> <button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="intLoading"
(click)="retrieveInterestSchedule()"
>
{{ intLoading ? 'Retrieving…' : 'Retrieve' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button> <button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button>
</div> </div>
@if (intCalcExcludedNpi.length > 0) {
@if (intSchedule; as sc) {
<div class="pos-section-label">Purchase Details</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Deal No / Cert No</span><input type="text" [value]="sc.dealNo + ' / ' + sc.certNo" readonly
/></label>
<label class="inv-form-field"
><span>Script</span><input type="text" [value]="sc.scriptCode + ' — ' + sc.scriptDesc" readonly
/></label>
<label class="inv-form-field"
><span>Face Value Held</span><input type="text" [value]="'₹' + (sc.faceValue | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Interest Rate</span><input type="text" [value]="sc.intRate + '%'" readonly
/></label>
<label class="inv-form-field"
><span>Coupon per Half-Year</span
><input type="text" [value]="'₹' + (sc.couponPerHalfYear | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Deal Date</span><input type="text" [value]="sc.dealDate" readonly
/></label>
<label class="inv-form-field"
><span>Last Interest Date</span><input type="text" [value]="sc.lastIntDate" readonly
/></label>
<label class="inv-form-field"
><span>Maturity Date</span><input type="text" [value]="sc.maturityDate" readonly
/></label>
<label class="inv-form-field"
><span>Accrued Interest Paid at Purchase</span
><input type="text" [value]="'₹' + (sc.purchasedAccruedInterest | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="'₹' + (sc.bookValue | number: '1.2-2')" readonly
/></label>
</div>
<div class="pos-section-label">
Interest Schedule ({{ sc.rows.length }} lines) — due up to {{ sc.asOnDate }}: ₹{{
intDueTotal | number: '1.2-2'
}}
</div>
<p class="pg-sub" style="padding: 0 16px">
Days are 30/360. A provision books the interest earned since the last coupon; the coupon then clears what is
already booked. Display only — nothing is posted to GL yet.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Type</th>
<th>Int Date</th>
<th>From</th>
<th>To</th>
<th class="num">Days</th>
<th class="num">Int %</th>
<th class="num">Gross Interest</th>
<th class="num">Already Booked</th>
<th class="num col-in">Amount</th>
<th>Accounting</th>
</tr>
</thead>
<tbody>
@for (r of sc.rows; track r.intDate) {
<tr [style.opacity]="r.due ? 1 : 0.6">
<td>
<span class="pg-chip">{{ r.type === 'COUPON' ? 'Coupon' : 'Provision' }}</span>
@if (r.due) {
<span class="pg-chip">Due</span>
}
</td>
<td>{{ r.intDate }}</td>
<td>{{ r.fromDate }}</td>
<td>{{ r.toDate }}</td>
<td class="num">{{ r.days }}</td>
<td class="num">{{ sc.intRate }}</td>
<td class="num">₹{{ r.grossInterest | number: '1.2-2' }}</td>
<td class="num">₹{{ r.alreadyBooked | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.2-2' }}</td>
<td style="white-space: nowrap; font-size: 12px">
@if (r.type === 'COUPON') {
<div>Dr Bank ₹{{ r.grossInterest | number: '1.0-0' }}</div>
@if (r.alreadyBooked > 0) {
<div>
Cr {{ interestReceivableGl.glCode }} Int. Receivable ₹{{ r.alreadyBooked | number: '1.0-0' }}
</div>
}
<div>Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}</div>
} @else {
<div>Dr {{ interestReceivableGl.glCode }} Int. Receivable ₹{{ r.amount | number: '1.0-0' }}</div>
<div>Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="pg-empty">
No interest lines — check the rate, last interest date and maturity date.
</td>
</tr>
}
</tbody>
</table>
<p class="pg-sub" style="padding: 8px 16px 12px">
Interest income GL: {{ sc.interestGlCode }} — {{ sc.interestGlName }}. Posting to GL will be enabled after
approval.
</p>
} @else {
<p class="pg-sub" style="padding: 0 16px 12px"> <p class="pg-sub" style="padding: 0 16px 12px">
{{ intCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on Select a Deal No and press Retrieve to see its interest schedule.
accrual basis (collectible on cash basis only, via Interest Receive in the Investment Interest module).
</p> </p>
} }
<div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th>Type</th>
<th class="num">Rate</th>
<th>Frequency</th>
<th class="num col-in">Interest to Post</th>
</tr>
</thead>
<tbody>
@for (h of intCalcTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.interestFreq }}</td>
<td class="num col-in">₹{{ interestAccrued(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No holdings match the selected filters.</td>
</tr>
}
</tbody>
</table>
</div> </div>
} }
@@ -26,8 +26,6 @@ import {
BOND_RATING_OPTIONS, BOND_RATING_OPTIONS,
INT_TYPE_OPTIONS, INT_TYPE_OPTIONS,
AUTHORITY_OPTIONS, AUTHORITY_OPTIONS,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
INVESTMENT_GL_BY_TYPE, INVESTMENT_GL_BY_TYPE,
INVESTMENT_GL_LOOKUP, INVESTMENT_GL_LOOKUP,
INTEREST_RECEIVABLE_GL, INTEREST_RECEIVABLE_GL,
@@ -42,8 +40,6 @@ import {
InterestFreq, InterestFreq,
INSTRUMENT_LABELS, INSTRUMENT_LABELS,
accruedInterest, accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity, daysToMaturity,
maturityAmount, maturityAmount,
addPeriod, addPeriod,
@@ -52,7 +48,13 @@ import {
todayDDMMYYYY todayDDMMYYYY
} from '../investment-data'; } from '../investment-data';
import { exportWorkbook } from '../investment-export'; import { exportWorkbook } from '../investment-export';
import { InvestmentPurchaseDto, InvestmentService, apiErrorMessage, toHolding } from '../investment.service'; import {
InvestmentInterestScheduleDto,
InvestmentPurchaseDto,
InvestmentService,
apiErrorMessage,
toHolding
} from '../investment.service';
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND'; type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
type BondTab = type BondTab =
@@ -286,17 +288,16 @@ export class BondInvestmentComponent implements OnInit {
redeemDate = this.asOnDate; redeemDate = this.asOnDate;
redeemRemarks = ''; redeemRemarks = '';
// Interest Calculation (legacy: From/To Date / Type / Script / Deal No / ALL + Process) — // 03 Interest Entry (legacy: Type / Script / Deal No + Retrieve) — the backend works out every
// posts interest accrued strictly within [intCalcFromDate, intCalcToDate], same period-based // half-yearly coupon and 31 Mar / 30 Sep closing provision of the holding. Display only:
// basis as the Investment Interest module's Govt./Bond Interest Calculation. // nothing is posted to GL until the posting rules are approved.
intCalcFromDate = addPeriod(this.asOnDate, 0, -3); intType: BondType | '' = '';
intCalcToDate = this.asOnDate; intScript = '';
intCalcType: BondType | '' = ''; intPurchaseId: number | null = null;
intCalcScript = ''; intAsOnDate = this.asOnDate;
intCalcDealNo = ''; intSchedule: InvestmentInterestScheduleDto | null = null;
intCalcAll = false; intLoading = false;
intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY; readonly interestReceivableGl = INTEREST_RECEIVABLE_GL;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
// Write-off // Write-off
writeoffCertNo = ''; writeoffCertNo = '';
@@ -892,78 +893,53 @@ export class BondInvestmentComponent implements OnInit {
} }
// -------------------- Interest calculation -------------------- // -------------------- Interest calculation --------------------
/** Holdings the Interest Calculation "Process" run would cover, given the filters. A /** Saved holdings for the Deal No dropdown, narrowed by Type and Script. */
* Non-Performing holding is always excluded — same accrual-basis rule as the Investment get intDealOptions(): InvestmentHolding[] {
* Interest module (it's still collectible on cash basis via Interest Receive there). */ return this.holdings.filter(
get intCalcTargets(): InvestmentHolding[] { (h) =>
const base = this.holdings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h)); h.id != null &&
if (this.intCalcAll) { h.status === 'ACTIVE' &&
return base; (!this.intType || h.instrumentType === this.intType) &&
(!this.intScript || h.scriptCode === this.intScript)
);
}
get intScriptOptions(): typeof SCRIPT_MASTER {
return this.scriptOptions.filter((s) => !this.intType || s.instrumentType === this.intType);
}
onIntFilterChange(): void {
if (this.intScript && !this.intScriptOptions.some((s) => s.scriptCode === this.intScript)) {
this.intScript = '';
} }
return base.filter((h) => { if (this.intPurchaseId != null && !this.intDealOptions.some((h) => h.id === this.intPurchaseId)) {
if (this.intCalcType && h.instrumentType !== this.intCalcType) return false; this.intPurchaseId = null;
if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false;
if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false;
return true;
});
}
get intCalcExcludedNpi(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && !canAccrueInterest(h));
}
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
onIntCalcTypeChange(): void {
if (this.intCalcType) {
this.intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.intCalcType];
} }
this.intSchedule = null;
} }
glName(glCode: string): string { retrieveInterestSchedule(): void {
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode; if (this.intPurchaseId == null) {
} this.toast('Select a Deal No first.');
/** Interest accrued strictly within [intCalcFromDate, intCalcToDate] — a period-based
* Interest Calculation & Post run, same basis as the Investment Interest module. */
interestAccrued(h: InvestmentHolding): number {
return accruedInterestBetween(h, this.intCalcFromDate || h.purchaseDate, this.intCalcToDate || this.asOnDate);
}
processInterest(): void {
const targets = this.intCalcTargets.filter((h) => this.interestAccrued(h) > 0);
if (targets.length === 0) {
this.toast('No holdings have interest due to be posted for this period.');
return; return;
} }
const toDate = this.intCalcToDate || this.asOnDate; this.intLoading = true;
const fromDate = this.intCalcFromDate || toDate; this.investmentService.getInterestSchedule(this.intPurchaseId, this.intAsOnDate || undefined).subscribe({
const glCode = this.intCalcGlCode; next: (schedule) => {
const glName = this.glName(glCode); this.intSchedule = schedule;
const newTxns: InvestmentTxn[] = targets.map((h, i) => { this.intLoading = false;
const amt = this.interestAccrued(h); },
h.interestPosted = (h.interestPosted ?? 0) + amt; error: (err) => {
h.interestPostedTillDate = toDate; this.intSchedule = null;
return { this.intLoading = false;
certNo: h.certNo, this.toast(apiErrorMessage(err));
instrumentType: h.instrumentType, }
txnType: 'INTEREST',
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
txnDate: toDate,
amount: amt,
particulars: `Interest calculated (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
glCode,
glName
};
}); });
this.txns = [ }
...newTxns,
...this.txns /** Total of the lines falling due up to the as-on date. */
]; get intDueTotal(): number {
this.intCalcFromDate = toDate; return (this.intSchedule?.rows ?? []).filter((r) => r.due).reduce((sum, r) => sum + r.amount, 0);
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.toast(
`Interest processed for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
} }
// -------------------- Sell / Redeem Entry -------------------- // -------------------- Sell / Redeem Entry --------------------
+44
View File
@@ -169,6 +169,14 @@ export class InvestmentService {
return this.http.get<InvestmentPurchaseDto[]>('/investment/purchases'); return this.http.get<InvestmentPurchaseDto[]>('/investment/purchases');
} }
/** Coupon and 31 Mar / 30 Sep provision lines of a holding up to maturity (calculated, not posted). */
getInterestSchedule(purchaseId: number, asOnDate?: string): Observable<InvestmentInterestScheduleDto> {
const params = asOnDate ? `?asOnDate=${encodeURIComponent(asOnDate)}` : '';
return this.http.get<InvestmentInterestScheduleDto>(
`/investment/purchases/${purchaseId}/interest-schedule${params}`
);
}
createPurchase(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> { createPurchase(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/purchases', body); return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/purchases', body);
} }
@@ -292,6 +300,42 @@ export function toHolding(p: InvestmentPurchaseDto): InvestmentHolding {
}; };
} }
/** One line of a holding's interest schedule (see InvestmentInterestScheduleData in the backend). */
export interface InvestmentInterestRowDto {
type: 'COUPON' | 'PROVISION';
intDate: string;
fromDate: string;
toDate: string;
days: number;
grossInterest: number;
alreadyBooked: number;
amount: number;
due: boolean;
}
export interface InvestmentInterestScheduleDto {
purchaseId: number;
dealNo: string;
certNo: string;
scriptCode: string;
scriptDesc: string;
faceValue: number;
unitValue: number;
balanceUnits: number;
purchaseRate: number;
bookValue: number;
intRate: number;
dealDate: string;
lastIntDate: string;
maturityDate: string;
purchasedAccruedInterest: number;
couponPerHalfYear: number;
asOnDate: string;
interestGlCode: string;
interestGlName: string;
rows: InvestmentInterestRowDto[];
}
/** The first validation / domain-rule message from a Fineract error response, for a toast. */ /** The first validation / domain-rule message from a Fineract error response, for a toast. */
export function apiErrorMessage(err: unknown): string { export function apiErrorMessage(err: unknown): string {
const e = err as HttpErrorResponse; const e = err as HttpErrorResponse;