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:
@@ -626,20 +626,14 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== 03 INTEREST ENTRY / CALCULATION ===================== -->
|
||||
<!-- ===================== 03 INTEREST ENTRY ===================== -->
|
||||
@if (activeTab === 'interest') {
|
||||
<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">
|
||||
<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">
|
||||
<span>Type</span>
|
||||
<select [(ngModel)]="intCalcType" (ngModelChange)="onIntCalcTypeChange()">
|
||||
<select [(ngModel)]="intType" (ngModelChange)="onIntFilterChange()">
|
||||
<option value="">-- All Types --</option>
|
||||
@for (o of bondTypeOptions; track o.value) {
|
||||
<option [value]="o.value">{{ o.label }}</option>
|
||||
@@ -648,67 +642,149 @@
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Script</span>
|
||||
<select [(ngModel)]="intCalcScript">
|
||||
<select [(ngModel)]="intScript" (ngModelChange)="onIntFilterChange()">
|
||||
<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>
|
||||
}
|
||||
</select>
|
||||
</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">
|
||||
<span>Post To (Interest Income GL)</span>
|
||||
<select [(ngModel)]="intCalcGlCode">
|
||||
@for (g of interestIncomeGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
<span>* Deal No</span>
|
||||
<select [(ngModel)]="intPurchaseId" (ngModelChange)="intSchedule = null">
|
||||
<option [ngValue]="null">-- Select --</option>
|
||||
@for (h of intDealOptions; track h.id) {
|
||||
<option [ngValue]="h.id">{{ h.dealNo }} — {{ h.scriptCode }} (Cert {{ h.certNo }})</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>As On Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intAsOnDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
</div>
|
||||
<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>
|
||||
</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">
|
||||
{{ intCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
|
||||
accrual basis (collectible on cash basis only, via Interest Receive in the Investment Interest module).
|
||||
Select a Deal No and press Retrieve to see its interest schedule.
|
||||
</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>
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ import {
|
||||
BOND_RATING_OPTIONS,
|
||||
INT_TYPE_OPTIONS,
|
||||
AUTHORITY_OPTIONS,
|
||||
INTEREST_INCOME_GL_MASTER,
|
||||
INTEREST_INCOME_GL_BY_TYPE,
|
||||
INVESTMENT_GL_BY_TYPE,
|
||||
INVESTMENT_GL_LOOKUP,
|
||||
INTEREST_RECEIVABLE_GL,
|
||||
@@ -42,8 +40,6 @@ import {
|
||||
InterestFreq,
|
||||
INSTRUMENT_LABELS,
|
||||
accruedInterest,
|
||||
accruedInterestBetween,
|
||||
canAccrueInterest,
|
||||
daysToMaturity,
|
||||
maturityAmount,
|
||||
addPeriod,
|
||||
@@ -52,7 +48,13 @@ import {
|
||||
todayDDMMYYYY
|
||||
} from '../investment-data';
|
||||
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 BondTab =
|
||||
@@ -286,17 +288,16 @@ export class BondInvestmentComponent implements OnInit {
|
||||
redeemDate = this.asOnDate;
|
||||
redeemRemarks = '';
|
||||
|
||||
// Interest Calculation (legacy: From/To Date / Type / Script / Deal No / ALL + Process) —
|
||||
// posts interest accrued strictly within [intCalcFromDate, intCalcToDate], same period-based
|
||||
// basis as the Investment Interest module's Govt./Bond Interest Calculation.
|
||||
intCalcFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
intCalcToDate = this.asOnDate;
|
||||
intCalcType: BondType | '' = '';
|
||||
intCalcScript = '';
|
||||
intCalcDealNo = '';
|
||||
intCalcAll = false;
|
||||
intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
|
||||
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
|
||||
// 03 Interest Entry (legacy: Type / Script / Deal No + Retrieve) — the backend works out every
|
||||
// half-yearly coupon and 31 Mar / 30 Sep closing provision of the holding. Display only:
|
||||
// nothing is posted to GL until the posting rules are approved.
|
||||
intType: BondType | '' = '';
|
||||
intScript = '';
|
||||
intPurchaseId: number | null = null;
|
||||
intAsOnDate = this.asOnDate;
|
||||
intSchedule: InvestmentInterestScheduleDto | null = null;
|
||||
intLoading = false;
|
||||
readonly interestReceivableGl = INTEREST_RECEIVABLE_GL;
|
||||
|
||||
// Write-off
|
||||
writeoffCertNo = '';
|
||||
@@ -892,78 +893,53 @@ export class BondInvestmentComponent implements OnInit {
|
||||
}
|
||||
|
||||
// -------------------- Interest calculation --------------------
|
||||
/** Holdings the Interest Calculation "Process" run would cover, given the filters. A
|
||||
* Non-Performing holding is always excluded — same accrual-basis rule as the Investment
|
||||
* Interest module (it's still collectible on cash basis via Interest Receive there). */
|
||||
get intCalcTargets(): InvestmentHolding[] {
|
||||
const base = this.holdings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
|
||||
if (this.intCalcAll) {
|
||||
return base;
|
||||
/** Saved holdings for the Deal No dropdown, narrowed by Type and Script. */
|
||||
get intDealOptions(): InvestmentHolding[] {
|
||||
return this.holdings.filter(
|
||||
(h) =>
|
||||
h.id != null &&
|
||||
h.status === 'ACTIVE' &&
|
||||
(!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.intCalcType && h.instrumentType !== this.intCalcType) return false;
|
||||
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];
|
||||
if (this.intPurchaseId != null && !this.intDealOptions.some((h) => h.id === this.intPurchaseId)) {
|
||||
this.intPurchaseId = null;
|
||||
}
|
||||
this.intSchedule = null;
|
||||
}
|
||||
|
||||
glName(glCode: string): string {
|
||||
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
|
||||
}
|
||||
|
||||
/** 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.');
|
||||
retrieveInterestSchedule(): void {
|
||||
if (this.intPurchaseId == null) {
|
||||
this.toast('Select a Deal No first.');
|
||||
return;
|
||||
}
|
||||
const toDate = this.intCalcToDate || this.asOnDate;
|
||||
const fromDate = this.intCalcFromDate || toDate;
|
||||
const glCode = this.intCalcGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||
const amt = this.interestAccrued(h);
|
||||
h.interestPosted = (h.interestPosted ?? 0) + amt;
|
||||
h.interestPostedTillDate = toDate;
|
||||
return {
|
||||
certNo: h.certNo,
|
||||
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.intLoading = true;
|
||||
this.investmentService.getInterestSchedule(this.intPurchaseId, this.intAsOnDate || undefined).subscribe({
|
||||
next: (schedule) => {
|
||||
this.intSchedule = schedule;
|
||||
this.intLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.intSchedule = null;
|
||||
this.intLoading = false;
|
||||
this.toast(apiErrorMessage(err));
|
||||
}
|
||||
});
|
||||
this.txns = [
|
||||
...newTxns,
|
||||
...this.txns
|
||||
];
|
||||
this.intCalcFromDate = toDate;
|
||||
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')}.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Total of the lines falling due up to the as-on date. */
|
||||
get intDueTotal(): number {
|
||||
return (this.intSchedule?.rows ?? []).filter((r) => r.due).reduce((sum, r) => sum + r.amount, 0);
|
||||
}
|
||||
|
||||
// -------------------- Sell / Redeem Entry --------------------
|
||||
|
||||
@@ -169,6 +169,14 @@ export class InvestmentService {
|
||||
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 }> {
|
||||
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. */
|
||||
export function apiErrorMessage(err: unknown): string {
|
||||
const e = err as HttpErrorResponse;
|
||||
|
||||
Reference in New Issue
Block a user