diff --git a/src/app/investment/bond-investment/bond-investment.component.html b/src/app/investment/bond-investment/bond-investment.component.html index bfcc033c6..9d516fe8d 100644 --- a/src/app/investment/bond-investment/bond-investment.component.html +++ b/src/app/investment/bond-investment/bond-investment.component.html @@ -626,20 +626,14 @@ } - + @if (activeTab === 'interest') {
-
Investment - Govt./Bond Interest calculation
+
Interest Entry — coupon and 31 Mar / 30 Sep provision schedule
- - - - +
- +
- @if (intCalcExcludedNpi.length > 0) { + + @if (intSchedule; as sc) { +
Purchase Details
+
+ + + + + + + + + + +
+ +
+ Interest Schedule ({{ sc.rows.length }} lines) — due up to {{ sc.asOnDate }}: ₹{{ + intDueTotal | number: '1.2-2' + }} +
+

+ 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. +

+ + + + + + + + + + + + + + + + + @for (r of sc.rows; track r.intDate) { + + + + + + + + + + + + + } @empty { + + + + } + +
TypeInt DateFromToDaysInt %Gross InterestAlready BookedAmountAccounting
+ {{ r.type === 'COUPON' ? 'Coupon' : 'Provision' }} + @if (r.due) { + Due + } + {{ r.intDate }}{{ r.fromDate }}{{ r.toDate }}{{ r.days }}{{ sc.intRate }}₹{{ r.grossInterest | number: '1.2-2' }}₹{{ r.alreadyBooked | number: '1.2-2' }}₹{{ r.amount | number: '1.2-2' }} + @if (r.type === 'COUPON') { +
Dr Bank ₹{{ r.grossInterest | number: '1.0-0' }}
+ @if (r.alreadyBooked > 0) { +
+ Cr {{ interestReceivableGl.glCode }} Int. Receivable ₹{{ r.alreadyBooked | number: '1.0-0' }} +
+ } +
Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}
+ } @else { +
Dr {{ interestReceivableGl.glCode }} Int. Receivable ₹{{ r.amount | number: '1.0-0' }}
+
Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}
+ } +
+ No interest lines — check the rate, last interest date and maturity date. +
+

+ Interest income GL: {{ sc.interestGlCode }} — {{ sc.interestGlName }}. Posting to GL will be enabled after + approval. +

+ } @else {

- {{ 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.

} -
Holdings matching the filter ({{ intCalcTargets.length }})
- - - - - - - - - - - - - @for (h of intCalcTargets; track h.certNo) { - - - - - - - - - } @empty { - - - - } - -
Cert NoScriptTypeRateFrequencyInterest to Post
- {{ h.certNo }} - {{ h.scriptName }}{{ instrumentLabel(h.instrumentType) }}{{ h.interestRate }}%{{ h.interestFreq }}₹{{ interestAccrued(h) | number: '1.2-2' }}
No holdings match the selected filters.
} diff --git a/src/app/investment/bond-investment/bond-investment.component.ts b/src/app/investment/bond-investment/bond-investment.component.ts index 414a45801..d08242915 100644 --- a/src/app/investment/bond-investment/bond-investment.component.ts +++ b/src/app/investment/bond-investment/bond-investment.component.ts @@ -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 -------------------- diff --git a/src/app/investment/investment.service.ts b/src/app/investment/investment.service.ts index 52a92ac78..9d04db151 100644 --- a/src/app/investment/investment.service.ts +++ b/src/app/investment/investment.service.ts @@ -169,6 +169,14 @@ export class InvestmentService { return this.http.get('/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 { + const params = asOnDate ? `?asOnDate=${encodeURIComponent(asOnDate)}` : ''; + return this.http.get( + `/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;