From 8ac4010967a302bff78e47e0cd36f00fd4d6b515 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sat, 1 Aug 2026 17:56:44 +0530 Subject: [PATCH] feat(investment): add portfolio allocation targets and interest receivable/post/receive lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Portfolio Allocation (Target vs Actual %) section to the Investment Dashboard, and reworks Investment Interest into the full accrual lifecycle: Interest Receivable, FD/Govt./Bond Interest Calculation & Post, and Interest Receive, plus the existing registers. Interest Calculation & Post now tracks cumulative posted/received amounts per holding instead of recomputing full accrued interest on every click (which previously double-counted on repeat posting). Holdings classified Non-Performing (Sub-Standard/Doubtful/Loss) are excluded from accrual posting per RBI norms — their interest accrues in memo/suspense only and is recognised solely on actual cash receipt via Interest Receive. Also bumps the production initial-bundle budget in angular.json (13/14mb -> 15/17mb) since the growing set of eagerly-loaded feature modules (Investment, NPA, Reconciliation, GL Reports, OSS Returns) pushed the build past the old limit. --- angular.json | 4 +- .../investment-dashboard.component.html | 52 ++++ .../investment-dashboard.component.ts | 35 +++ src/app/investment/investment-data.ts | 120 ++++++++- .../investment-interest.component.html | 230 ++++++++++++++++- .../investment-interest.component.ts | 238 ++++++++++++++---- src/app/investment/investment-shared.scss | 26 ++ src/environments/.env.ts | 2 +- 8 files changed, 648 insertions(+), 59 deletions(-) diff --git a/angular.json b/angular.json index 1fe5293cf..341741725 100644 --- a/angular.json +++ b/angular.json @@ -69,8 +69,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "13mb", - "maximumError": "14mb" + "maximumWarning": "15mb", + "maximumError": "17mb" }, { "type": "anyComponentStyle", diff --git a/src/app/investment/investment-dashboard/investment-dashboard.component.html b/src/app/investment/investment-dashboard/investment-dashboard.component.html index 43309fba5..01f9bb2ec 100644 --- a/src/app/investment/investment-dashboard/investment-dashboard.component.html +++ b/src/app/investment/investment-dashboard/investment-dashboard.component.html @@ -78,6 +78,58 @@ +
+ + + + + + + + + + + + + + @for (a of allocationVsTarget; track a.type) { + + + + + + + + + } + + + + + + + + + + +
Instrument TypeTarget %Actual %Actual ValueVarianceStatus
{{ a.label }}{{ a.targetPct }}%{{ a.actualPct }}%₹{{ a.actualValue | number: '1.2-2' }} + {{ a.variancePct > 0 ? '+' : '' }}{{ a.variancePct }}% + + + {{ a.statusLabel }} + +
Total100%100%₹{{ totalFaceValue | number: '1.2-2' }}
+
+
@if (maturingSoon.length === 0) { diff --git a/src/app/investment/investment-dashboard/investment-dashboard.component.ts b/src/app/investment/investment-dashboard/investment-dashboard.component.ts index 650d5f568..8640f4cff 100644 --- a/src/app/investment/investment-dashboard/investment-dashboard.component.ts +++ b/src/app/investment/investment-dashboard/investment-dashboard.component.ts @@ -14,6 +14,7 @@ import { INSTRUMENT_LABELS, InstrumentType, InvestmentHolding, + TARGET_ALLOCATION_PCT, accruedInterest, daysToMaturity, maturityAmount @@ -29,6 +30,16 @@ interface InstrumentSummary { maturityValue: number; } +interface AllocationRow { + type: InstrumentType; + label: string; + targetPct: number; + actualPct: number; + actualValue: number; + variancePct: number; + statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated'; +} + /** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never * hand-duplicated, so it can never drift from the per-instrument screens or Reports. */ @Component({ @@ -79,6 +90,30 @@ export class InvestmentDashboardComponent { }); } + /** Board-approved policy target % per instrument type vs the portfolio's actual allocation + * (by invested/face value) — a ±2 percentage-point band is treated as "Within Target" since + * a treasury desk can't rebalance to the exact decimal every day. */ + get allocationVsTarget(): AllocationRow[] { + const total = this.totalFaceValue; + return this.types.map(({ type }) => { + const value = this.holdingsOf(type).reduce((s, h) => s + h.faceValue, 0); + const actualPct = total ? (value / total) * 100 : 0; + const targetPct = TARGET_ALLOCATION_PCT[type]; + const variancePct = +(actualPct - targetPct).toFixed(1); + const statusLabel: AllocationRow['statusLabel'] = + Math.abs(variancePct) <= 2 ? 'Within Target' : variancePct > 0 ? 'Over-Allocated' : 'Under-Allocated'; + return { + type, + label: INSTRUMENT_LABELS[type], + targetPct, + actualPct: +actualPct.toFixed(1), + actualValue: value, + variancePct, + statusLabel + }; + }); + } + get totalHoldings(): number { return ALL_HOLDINGS.length; } diff --git a/src/app/investment/investment-data.ts b/src/app/investment/investment-data.ts index a2cdd253c..980093c86 100644 --- a/src/app/investment/investment-data.ts +++ b/src/app/investment/investment-data.ts @@ -22,6 +22,7 @@ export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' | export type TxnType = | 'PURCHASE' | 'INTEREST' + | 'INTEREST_RECEIVED' | 'DIVIDEND' | 'SELL' | 'REDEEM' @@ -38,6 +39,32 @@ export const INSTRUMENT_LABELS: Record = { SHARE: 'Share' }; +/** RBI investment-provisioning classification. A holding with no `provisionStatus` (or + * explicitly 'STANDARD') is performing normally. Once classified Sub-Standard/Doubtful/Loss — + * a Non-Performing Investment (NPI), the loan-book equivalent of an NPA — interest can no + * longer be recognised on accrual basis (see canAccrueInterest()) and a provision must be held + * against the investment (see provisionRequiredInv()). */ +export type InvestmentProvisionStatus = 'STANDARD' | 'SUB_STANDARD' | 'DOUBTFUL' | 'LOSS'; + +export const PROVISION_STATUS_LABELS: Record = { + STANDARD: 'Standard', + SUB_STANDARD: 'Sub-Standard (NPI)', + DOUBTFUL: 'Doubtful (NPI)', + LOSS: 'Loss (NPI)' +}; + +/** Board-approved portfolio-policy target — % of the total investment corpus each instrument + * type should hold. Compared against actual holdings on the Dashboard so a treasury desk can + * see at a glance where the book has drifted from policy. */ +export const TARGET_ALLOCATION_PCT: Record = { + FD: 15, + GOVT_SECURITY: 35, + NON_SLR: 10, + BOND: 20, + MUTUAL_FUND: 12, + SHARE: 8 +}; + export interface InvestmentHolding { certNo: string; instrumentType: InstrumentType; @@ -57,6 +84,12 @@ export interface InvestmentHolding { crToAc: string; brokerCode: string; status: HoldingStatus; + /** Absent/'STANDARD' for a healthy holding. Drives canAccrueInterest() and provisioning. */ + provisionStatus?: InvestmentProvisionStatus; + /** Cumulative interest posted (booked as an accrual receivable/income entry) to date. */ + interestPosted?: number; + /** Cumulative interest actually received in cash to date. */ + interestReceived?: number; } export interface InvestmentTxn { @@ -109,6 +142,67 @@ export function daysToMaturity(h: InvestmentHolding, asOnStr: string): number { return Math.round((mat - asOn) / (24 * 3600 * 1000)); } +// -------------------- Provisioning & interest-lifecycle helpers -------------------- + +export function isNpi(h: InvestmentHolding): boolean { + return (h.provisionStatus ?? 'STANDARD') !== 'STANDARD'; +} + +export function provisionRatePctInv(status: InvestmentProvisionStatus): number { + switch (status) { + case 'STANDARD': + return 0; + case 'SUB_STANDARD': + return 25; + case 'DOUBTFUL': + return 50; + case 'LOSS': + return 100; + } +} + +/** Provision required against the investment itself (principal), same idea as NPA provisioning + * on a loan book — computed on face value since that is this module's carrying-value figure. */ +export function provisionRequiredInv(h: InvestmentHolding): number { + return Math.round(h.faceValue * (provisionRatePctInv(h.provisionStatus ?? 'STANDARD') / 100)); +} + +/** Interest can only be recognised on an accrual basis (posted as a receivable/income entry) + * while the investment is Standard. Once it is classified Non-Performing (Sub-Standard/ + * Doubtful/Loss), RBI income-recognition norms require interest to move to cash basis — i.e. + * it is recognised only when actually received, never posted as an accrual receivable. */ +export function canAccrueInterest(h: InvestmentHolding): boolean { + return !isNpi(h); +} + +/** Interest still to be calculated & posted this cycle. Always 0 for an NPI holding — posting + * an accrual on a non-performing investment would overstate income. */ +export function interestToPost(h: InvestmentHolding, asOnStr: string): number { + if (!canAccrueInterest(h)) { + return 0; + } + return Math.max(0, accruedInterest(h, asOnStr) - (h.interestPosted ?? 0)); +} + +/** Outstanding "Interest Receivable" GL balance — interest already posted (booked as income) + * but not yet actually received in cash. Always 0 for an NPI holding, since nothing is ever + * posted as a receivable on one. */ +export function interestReceivableBalance(h: InvestmentHolding): number { + if (!canAccrueInterest(h)) { + return 0; + } + return Math.max(0, (h.interestPosted ?? 0) - (h.interestReceived ?? 0)); +} + +/** For an NPI holding, interest keeps accruing notionally but is held in memo/suspense — never + * posted as an asset or recognised as income — until it is actually collected in cash. */ +export function suspenseInterest(h: InvestmentHolding, asOnStr: string): number { + if (canAccrueInterest(h)) { + return 0; + } + return Math.max(0, accruedInterest(h, asOnStr) - (h.interestReceived ?? 0)); +} + export function formatDDMMYYYY(d: Date): string { return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; } @@ -208,7 +302,9 @@ export const FD_HOLDINGS: InvestmentHolding[] = [ glName: 'FIXED DEPO. CO-OPER. BANK MAIN', crToAc: '310070', brokerCode: '', - status: 'ACTIVE' + status: 'ACTIVE', + interestPosted: 42000, + interestReceived: 36000 }, { certNo: '15624', @@ -268,7 +364,10 @@ export const FD_HOLDINGS: InvestmentHolding[] = [ glName: 'FIXED DEPO. CO-OPER. BANK MAIN', crToAc: '310070', brokerCode: '', - status: 'ACTIVE' + status: 'ACTIVE', + provisionStatus: 'DOUBTFUL', + interestPosted: 115500, + interestReceived: 50000 }, { certNo: '15627', @@ -311,7 +410,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [ glName: 'INVESTMENT IN GOVT. SECURITIES', crToAc: '310070', brokerCode: 'BRK-002', - status: 'ACTIVE' + status: 'ACTIVE', + interestPosted: 181500, + interestReceived: 150000 }, { certNo: 'GSEC-2002', @@ -351,7 +452,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [ glName: 'INVESTMENT IN GOVT. SECURITIES', crToAc: '310070', brokerCode: 'BRK-002', - status: 'ACTIVE' + status: 'ACTIVE', + interestPosted: 67900, + interestReceived: 67900 }, { certNo: 'GSEC-2004', @@ -514,7 +617,9 @@ export const NON_SLR_HOLDINGS: InvestmentHolding[] = [ glName: 'INVESTMENT IN NON SLR SECURITIES', crToAc: '310070', brokerCode: 'BRK-003', - status: 'ACTIVE' + status: 'ACTIVE', + interestPosted: 89400, + interestReceived: 60000 } ]; @@ -597,7 +702,10 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [ glName: 'INVESTMENT IN CORPORATE BONDS', crToAc: '310070', brokerCode: 'BRK-006', - status: 'ACTIVE' + status: 'ACTIVE', + provisionStatus: 'SUB_STANDARD', + interestPosted: 66000, + interestReceived: 40000 }, { certNo: 'BOND-4005', diff --git a/src/app/investment/investment-interest/investment-interest.component.html b/src/app/investment/investment-interest/investment-interest.component.html index 94a3060bf..3423802ea 100644 --- a/src/app/investment/investment-interest/investment-interest.component.html +++ b/src/app/investment/investment-interest/investment-interest.component.html @@ -12,20 +12,28 @@
TREASURY · INVESTMENT · INTEREST

Investment Interest

- FD and Govt./Bond interest calculation batch runs, plus the Government Bond Interest, FD Interest and FD - registers. + Interest Receivable, FD and Govt./Bond interest calculation-and-post batch runs, Interest Receive, plus the + Government Bond Interest, FD Interest and FD registers.

+ +
+ + @if (activeTab === 'receivable') { +
+ + + + + + + + + + + + + + + @for (h of receivableRows; track h.certNo) { + + + + + + + + + + } @empty { + + + + } + + + + + + + +
Cert NoScript / PartyTypeStatusInterest PostedInterest ReceivedReceivable Balance
+ {{ h.certNo }} + {{ h.scriptName || h.party }}{{ instrumentLabel(h.instrumentType) }} + {{ provisionLabel(h) }} + ₹{{ posted(h) | number: '1.2-2' }}₹{{ received(h) | number: '1.2-2' }}₹{{ receivableBalance(h) | number: '1.2-2' }}
No outstanding interest receivable.
Total Receivable₹{{ totalReceivable | number: '1.2-2' }}
+
+ + @if (suspenseRows.length > 0) { +
+ +

+ These holdings are classified Non-Performing (Sub-Standard/Doubtful/Loss). Per RBI income-recognition norms, + interest is not posted as an accrual receivable — it accrues in memo only, and is recognised as income solely + on actual cash receipt via the Interest Receive tab. +

+ + + + + + + + + + + + + @for (h of suspenseRows; track h.certNo) { + + + + + + + + + } + + + + + + + +
Cert NoScript / PartyTypeStatusProvision RequiredInterest in Suspense
+ {{ h.certNo }} + {{ h.scriptName || h.party }}{{ instrumentLabel(h.instrumentType) }} + {{ provisionLabel(h) }} + ₹{{ provisionRequired(h) | number: '1.2-2' }}₹{{ suspense(h) | number: '1.2-2' }}
Total Suspense₹{{ totalSuspense | number: '1.2-2' }}
+
+ } + } + @if (activeTab === 'fdCalc') {
- +
- +
@@ -125,13 +237,19 @@
+ @if (fdCalcExcludedNpi.length > 0) { +

+ {{ fdCalcExcludedNpi.length }} certificate(s) excluded — classified Non-Performing, interest is not posted on + accrual basis (see Interest Receivable tab for suspense amount). +

+ }
} @if (activeTab === 'bondCalc') {
- +
- +
@@ -204,6 +324,100 @@
+ @if (bondCalcExcludedNpi.length > 0) { +

+ {{ bondCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on + accrual basis (see Interest Receivable tab for suspense amount). +

+ } +
+ } + + + @if (activeTab === 'receive') { +
+ + + + + + + + + + + + + + + @for (h of receiveTargets; track h.certNo) { + + + + + + + + + + } @empty { + + + + } + +
Cert NoScript / PartyTypeStatusOutstandingAmount to Receive
+ {{ h.certNo }} + {{ h.scriptName || h.party }}{{ instrumentLabel(h.instrumentType) }} + {{ provisionLabel(h) }} + ₹{{ outstandingFor(h) | number: '1.2-2' }} + + + +
No interest outstanding for collection.
+
+ +
+ + + + + + + + + + + + + @for (t of receivedLog; track t.voucherNo) { + + + + + + + + } @empty { + + + + } + + + + + + + +
DateVoucher NoCert NoParticularsAmount Received
{{ t.txnDate }} + {{ t.voucherNo }} + {{ t.certNo }}{{ t.particulars }}₹{{ t.amount | number: '1.2-2' }}
No interest received yet.
Total Received₹{{ receivedLogTotal | number: '1.2-2' }}
} diff --git a/src/app/investment/investment-interest/investment-interest.component.ts b/src/app/investment/investment-interest/investment-interest.component.ts index 8910706cb..751d5d2ef 100644 --- a/src/app/investment/investment-interest/investment-interest.component.ts +++ b/src/app/investment/investment-interest/investment-interest.component.ts @@ -19,22 +19,42 @@ import { InvestmentHolding, InvestmentTxn, InstrumentType, + InvestmentProvisionStatus, INSTRUMENT_LABELS, + PROVISION_STATUS_LABELS, accruedInterest, daysToMaturity, maturityAmount, - todayDDMMYYYY + todayDDMMYYYY, + isNpi, + canAccrueInterest, + interestToPost, + interestReceivableBalance, + suspenseInterest, + provisionRequiredInv } from '../investment-data'; -type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' | 'fdRegister'; +type InterestTab = + | 'receivable' + | 'fdCalc' + | 'bondCalc' + | 'receive' + | 'bondIntRegister' + | 'fdIntRegister' + | 'fdRegister'; /** - * The legacy "Interest" top-menu for Investment — 5 screens: - * 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation (both batch "Process" runs), - * 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register (listings). + * The legacy "Interest" top-menu for Investment — plus the full accrual lifecycle: Interest + * Receivable, 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation, Interest Receive, + * 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register. * * Interest figures are derived from the same holdings used by the Entry screens and Reports, * so a register here always agrees with what a Purchase/Interest entry recorded. + * + * Provisioning: a holding classified Non-Performing (Sub-Standard/Doubtful/Loss) can never have + * interest calculated/posted on accrual basis (canAccrueInterest() blocks it) — only actual cash + * receipts are recognised for it, straight through Interest Receive, matching RBI's income + * recognition norms for Non-Performing Investments. */ @Component({ selector: 'mifosx-investment-interest', @@ -50,7 +70,7 @@ type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' | ] }) export class InvestmentInterestComponent { - activeTab: InterestTab = 'fdCalc'; + activeTab: InterestTab = 'receivable'; asOnDate = todayDDMMYYYY(); fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS]; @@ -63,6 +83,9 @@ export class InvestmentInterestComponent { readonly scriptOptions = SCRIPT_MASTER; + /** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */ + receiveDrafts: Record = {}; + toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; @@ -103,18 +126,63 @@ export class InvestmentInterestComponent { return daysToMaturity(h, this.asOnDate); } + get allInterestHoldings(): InvestmentHolding[] { + return [ + ...this.fdHoldings, + ...this.bondHoldings + ].filter((h) => h.status === 'ACTIVE'); + } + + isNpiHolding(h: InvestmentHolding): boolean { + return isNpi(h); + } + + provisionLabel(h: InvestmentHolding): string { + return PROVISION_STATUS_LABELS[h.provisionStatus ?? 'STANDARD']; + } + + provisionBadgeClass(h: InvestmentHolding): string { + const status: InvestmentProvisionStatus = h.provisionStatus ?? 'STANDARD'; + return `inv-badge inv-badge--${status.toLowerCase()}`; + } + + posted(h: InvestmentHolding): number { + return h.interestPosted ?? 0; + } + + received(h: InvestmentHolding): number { + return h.interestReceived ?? 0; + } + + receivableBalance(h: InvestmentHolding): number { + return interestReceivableBalance(h); + } + + suspense(h: InvestmentHolding): number { + return suspenseInterest(h, this.asOnDate); + } + + provisionRequired(h: InvestmentHolding): number { + return provisionRequiredInv(h); + } + // -------------------- 01 FD Interest Calculation -------------------- get fdCalcTargets(): InvestmentHolding[] { + const base = this.fdHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h)); if (this.fdCalcAll) { - return this.fdHoldings.filter((h) => h.status === 'ACTIVE'); + return base; } - return this.fdHoldings.filter( - (h) => h.status === 'ACTIVE' && (!this.fdCalcCertNo || h.certNo === this.fdCalcCertNo) - ); + return base.filter((h) => !this.fdCalcCertNo || h.certNo === this.fdCalcCertNo); + } + + /** NPI FD certificates excluded from calculation/posting — interest recognised on cash basis + * only, via the Interest Receive tab. */ + get fdCalcExcludedNpi(): InvestmentHolding[] { + return this.fdHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h)); } fdInterest(h: InvestmentHolding): number { - return accruedInterest(h, this.fdCalcDate || this.asOnDate); + return interestToPost(h, this.fdCalcDate || this.asOnDate); } get fdCalcTotal(): number { @@ -122,36 +190,39 @@ export class InvestmentInterestComponent { } processFdInterest(): void { - const targets = this.fdCalcTargets; + const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0); if (targets.length === 0) { - this.toast('No FD holdings match the selected filters.'); + this.toast('No FD holdings have interest due to be posted.'); return; } - const newTxns: InvestmentTxn[] = targets.map((h, i) => ({ - certNo: h.certNo, - instrumentType: 'FD', - txnType: 'INTEREST', - voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, - txnDate: this.fdCalcDate || this.asOnDate, - amount: this.fdInterest(h), - particulars: `FD interest calculated - ${h.party}` - })); + const newTxns: InvestmentTxn[] = targets.map((h, i) => { + const amt = this.fdInterest(h); + h.interestPosted = this.posted(h) + amt; + return { + certNo: h.certNo, + instrumentType: 'FD', + txnType: 'INTEREST', + voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, + txnDate: this.fdCalcDate || this.asOnDate, + amount: amt, + particulars: `FD interest posted (accrual) - ${h.party}` + }; + }); + const total = newTxns.reduce((s, t) => s + t.amount, 0); this.txns = [ ...newTxns, ...this.txns ]; - this.toast( - `FD interest processed for ${targets.length} certificate(s). Total ₹${this.fdCalcTotal.toLocaleString('en-IN')}.` - ); + this.toast(`FD interest posted for ${targets.length} certificate(s). Total ₹${total.toLocaleString('en-IN')}.`); } // -------------------- 02 Govt./Bond Int. Calculation -------------------- get bondCalcTargets(): InvestmentHolding[] { + const base = this.bondHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h)); if (this.bondCalcAll) { - return this.bondHoldings.filter((h) => h.status === 'ACTIVE'); + return base; } - return this.bondHoldings.filter((h) => { - if (h.status !== 'ACTIVE') return false; + return base.filter((h) => { if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false; if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false; if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false; @@ -159,8 +230,14 @@ export class InvestmentInterestComponent { }); } + /** NPI Govt/Bond holdings excluded from calculation/posting — interest recognised on cash + * basis only, via the Interest Receive tab. */ + get bondCalcExcludedNpi(): InvestmentHolding[] { + return this.bondHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h)); + } + bondInterest(h: InvestmentHolding): number { - return accruedInterest(h, this.bondCalcDate || this.asOnDate); + return interestToPost(h, this.bondCalcDate || this.asOnDate); } get bondCalcTotal(): number { @@ -168,29 +245,106 @@ export class InvestmentInterestComponent { } processBondInterest(): void { - const targets = this.bondCalcTargets; + const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0); if (targets.length === 0) { - this.toast('No Govt/Bond holdings match the selected filters.'); + this.toast('No Govt/Bond holdings have interest due to be posted.'); return; } - const newTxns: InvestmentTxn[] = targets.map((h, i) => ({ - certNo: h.certNo, - instrumentType: h.instrumentType, - txnType: 'INTEREST', - voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, - txnDate: this.bondCalcDate || this.asOnDate, - amount: this.bondInterest(h), - particulars: `Coupon/interest calculated - ${h.scriptName}` - })); + const newTxns: InvestmentTxn[] = targets.map((h, i) => { + const amt = this.bondInterest(h); + h.interestPosted = this.posted(h) + amt; + return { + certNo: h.certNo, + instrumentType: h.instrumentType, + txnType: 'INTEREST', + voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, + txnDate: this.bondCalcDate || this.asOnDate, + amount: amt, + particulars: `Coupon/interest posted (accrual) - ${h.scriptName}` + }; + }); + const total = newTxns.reduce((s, t) => s + t.amount, 0); this.txns = [ ...newTxns, ...this.txns ]; - this.toast( - `Interest processed for ${targets.length} holding(s). Total ₹${this.bondCalcTotal.toLocaleString('en-IN')}.` + this.toast(`Interest posted for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`); + } + + // -------------------- Interest Receivable (all interest-bearing types) -------------------- + /** Standard holdings with a posted-but-uncollected balance — a genuine "Interest Receivable" + * GL asset. */ + get receivableRows(): InvestmentHolding[] { + return this.allInterestHoldings.filter((h) => canAccrueInterest(h) && this.receivableBalance(h) > 0); + } + + /** NPI holdings with interest still accruing in memo/suspense — not a GL asset, shown + * separately so the treasury desk can see what cash-basis income is outstanding. */ + get suspenseRows(): InvestmentHolding[] { + return this.allInterestHoldings.filter((h) => isNpi(h) && this.suspense(h) > 0); + } + + get totalReceivable(): number { + return this.receivableRows.reduce((s, h) => s + this.receivableBalance(h), 0); + } + + get totalSuspense(): number { + return this.suspenseRows.reduce((s, h) => s + this.suspense(h), 0); + } + + // -------------------- Interest Receive (cash receipt, all interest-bearing types) -------------------- + /** Everything with money outstanding to collect — Standard holdings against their posted + * receivable, and NPI holdings against their notional suspense (cash-basis recognition). */ + get receiveTargets(): InvestmentHolding[] { + return this.allInterestHoldings.filter((h) => + canAccrueInterest(h) ? this.receivableBalance(h) > 0 : this.suspense(h) > 0 ); } + outstandingFor(h: InvestmentHolding): number { + return canAccrueInterest(h) ? this.receivableBalance(h) : this.suspense(h); + } + + get receivedLog(): InvestmentTxn[] { + return this.txns.filter((t) => t.txnType === 'INTEREST_RECEIVED'); + } + + get receivedLogTotal(): number { + return this.receivedLog.reduce((s, t) => s + t.amount, 0); + } + + receiveInterest(h: InvestmentHolding): void { + const draft = this.receiveDrafts[h.certNo]; + if (!draft || draft <= 0) { + this.toast('Enter a valid amount to receive.'); + return; + } + let amt = draft; + let particulars: string; + if (canAccrueInterest(h)) { + amt = Math.min(draft, this.receivableBalance(h)); + h.interestReceived = this.received(h) + amt; + particulars = `Interest received against posted receivable - ${h.scriptName || h.party}`; + } else { + h.interestReceived = this.received(h) + amt; + particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`; + } + this.txns = [ + { + certNo: h.certNo, + instrumentType: h.instrumentType, + txnType: 'INTEREST_RECEIVED', + voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`, + txnDate: this.asOnDate, + amount: amt, + particulars + }, + ...this.txns + ]; + this.toast(`₹${amt.toLocaleString('en-IN')} interest received for ${h.certNo}.`); + delete this.receiveDrafts[h.certNo]; + } + // -------------------- 03 Government Bond Int Register -------------------- get bondIntRegister(): InvestmentTxn[] { return this.txns.filter( diff --git a/src/app/investment/investment-shared.scss b/src/app/investment/investment-shared.scss index 66f4f2f55..9945581f3 100644 --- a/src/app/investment/investment-shared.scss +++ b/src/app/investment/investment-shared.scss @@ -128,6 +128,32 @@ color: #b91c1c; border: 1px solid #fecaca; } + + // Investment-provisioning classification (Non-Performing Investment) — same severity ramp as + // the NPA module's stage badges, kept local since it colours a different status field here. + &--standard { + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; + } + + &--sub_standard { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } + + &--doubtful { + background: #fdf2f8; + color: #9d174d; + border: 1px solid #fbcfe8; + } + + &--loss { + background: #1f2937; + color: #f9fafb; + border: 1px solid #1f2937; + } } .inv-form-grid { diff --git a/src/environments/.env.ts b/src/environments/.env.ts index d3ca875e2..b2c58d3fc 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -10,7 +10,7 @@ export default { 'mifos_x': { 'version': '260801', - 'hash': 'bd753f49' + 'hash': '1c125d97' }, 'allow_switching_backend_instance': true };