feat(investment,npa): RBI-grounded targets, GL-tracked interest posting, date/search pickers

- Correct Investment portfolio allocation and Loan Origination product-mix targets against
  RBI's UCB SLR/Non-SLR framework and PSL circulars, with a Target Basis column distinguishing
  RBI-mandated vs board-policy rows (Excel export included).
- Expand Investment holdings dataset with more near-term maturities so the Due-Date Diary
  reflects a realistic spread across FD/Govt Security/Non-SLR/Bond.
- Add From/To period-based Interest Calculation & Post (with GL selection for both posting and
  receiving) across the Investment Interest module, FD Entry and Bond Entry screens; fix a bug
  where a posted period kept re-appearing as due since accrual wasn't tracked per-holding.
- Add a reusable DD-MM-YYYY date-picker component and roll it out across all Investment date
  fields.
- NPA Summary Report: drop SMA-0/1/2 from the stage-wise table (kept in Day-wise Overdue only).
- NPA Recovery Report: add Account Type / NPA Category / Target-vs-Actual columns, a shared
  Account Type filter driving both the account picker and the register, and a searchable
  (fixed-position, not clipped by card overflow) account select for long lists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:26:19 +05:30
co-authored by Claude Sonnet 5
parent d7f5bfe5f5
commit ebe67cc410
20 changed files with 1401 additions and 226 deletions
@@ -192,8 +192,8 @@
><span>Deal No</span><input type="text" [(ngModel)]="purchaseForm.dealNo" readonly
/></label>
<label class="inv-form-field"
><span>* Deal Date</span><input type="text" [(ngModel)]="purchaseForm.dealDate" placeholder="DD-MM-YYYY"
/></label>
><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.dealDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Deal Time (24 hr, HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
@@ -221,8 +221,8 @@
</label>
<label class="inv-form-field"
><span>Settlement Dt</span
><input type="text" [(ngModel)]="purchaseForm.settlementDt" placeholder="DD-MM-YYYY"
/></label>
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.settlementDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>* Party Code</span>
<select [(ngModel)]="purchaseForm.partyCode">
@@ -333,8 +333,8 @@
</select>
</label>
<label class="inv-form-field"
><span>* Issue Date</span><input type="text" [(ngModel)]="purchaseForm.issueDate" placeholder="DD-MM-YYYY"
/></label>
><span>* Issue Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Period(Year)</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
/></label>
@@ -354,8 +354,8 @@
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
/></label>
<label class="inv-form-field"
><span>Meet Dt</span><input type="text" [(ngModel)]="purchaseForm.meetDt" placeholder="DD-MM-YYYY"
/></label>
><span>Meet Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
/></label>
@@ -394,11 +394,11 @@
</select>
</label>
<label class="inv-form-field"
><span>Int Date</span><input type="text" [(ngModel)]="purchaseForm.intDate" placeholder="DD-MM-YYYY"
/></label>
><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Last Int.dt</span><input type="text" [(ngModel)]="purchaseForm.lastIntDt" placeholder="DD-MM-YYYY"
/></label>
><span>* Last Int.dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Accrued Interest (computed)</span
><input type="text" [value]="purchaseAccruedInterest | number: '1.2-2'" readonly
@@ -554,8 +554,8 @@
/></label>
}
<label class="inv-form-field"
><span>Redemption Date *</span><input type="text" [(ngModel)]="redeemDate" placeholder="DD-MM-YYYY"
/></label>
><span>Redemption Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="redeemDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Redemption Proceeds (computed)</span
><input type="text" [value]="'₹' + (redeemAmount | number: '1.2-2')" readonly
@@ -575,11 +575,14 @@
<div class="pos-section-label">Investment - Govt./Bond Interest calculation</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="intCalcDate" placeholder="DD-MM-YYYY"
/></label>
><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">
<select [(ngModel)]="intCalcType" (ngModelChange)="onIntCalcTypeChange()">
<option value="">-- All Types --</option>
@for (o of bondTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
@@ -599,11 +602,25 @@
<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>
}
</select>
</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--ghost" (click)="setTab('holdings')">Exit</button>
</div>
@if (intCalcExcludedNpi.length > 0) {
<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).
</p>
}
<div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div>
<table class="pg-table">
<thead>
@@ -694,8 +711,8 @@
><span>TBill GL.A/C.</span><input type="text" [(ngModel)]="sellForm.tbillGlAc"
/></label>
<label class="inv-form-field"
><span>* Deal Date</span><input type="text" [(ngModel)]="sellForm.dealDate" placeholder="DD-MM-YYYY"
/></label>
><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="sellForm.dealDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Deal Time</span><input type="text" [(ngModel)]="sellForm.dealTime" placeholder="HH:mm:ss"
/></label>
@@ -708,8 +725,8 @@
</select>
</label>
<label class="inv-form-field"
><span>Settlement Dt</span><input type="text" [(ngModel)]="sellForm.settlementDt" placeholder="DD-MM-YYYY"
/></label>
><span>Settlement Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="sellForm.settlementDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Face Value</span><input type="number" [(ngModel)]="sellForm.faceValue" min="0"
/></label>
@@ -942,8 +959,8 @@
</select>
</label>
<label class="inv-form-field"
><span>Transfer Date *</span><input type="text" [(ngModel)]="goiTransferDate" placeholder="DD-MM-YYYY"
/></label>
><span>Transfer Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="goiTransferDate"></mifosx-date-ddmmyyyy
></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveGoiTransfer()">Save</button>
@@ -958,8 +975,8 @@
<div class="pos-section-label">Investment - Investment Depreciation (Mark-to-Market Provisioning)</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Valuation Date</span><input type="text" [(ngModel)]="depreciationDate" placeholder="DD-MM-YYYY"
/></label>
><span>Valuation Date</span><mifosx-date-ddmmyyyy [(ngModel)]="depreciationDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Category</span>
<select [(ngModel)]="depreciationCategory">
@@ -1043,8 +1060,8 @@
><span>Units to Transfer *</span><input type="number" [(ngModel)]="mfTransferUnits" min="0" step="0.001"
/></label>
<label class="inv-form-field"
><span>Transfer Date *</span><input type="text" [(ngModel)]="mfTransferDate" placeholder="DD-MM-YYYY"
/></label>
><span>Transfer Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="mfTransferDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Transfer Value (computed)</span
><input type="text" [value]="'₹' + (mfTransferValue | number: '1.2-2')" readonly
@@ -1069,6 +1086,7 @@
<th>Cert No</th>
<th>Type</th>
<th>Particulars</th>
<th>GL Posted</th>
<th class="num">Amount</th>
</tr>
</thead>
@@ -1082,11 +1100,12 @@
<td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No transactions yet.</td>
<td colspan="7" class="pg-empty">No transactions yet.</td>
</tr>
}
</tbody>
@@ -9,6 +9,7 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
@@ -30,12 +31,16 @@ import {
BOND_RATING_OPTIONS,
INT_TYPE_OPTIONS,
AUTHORITY_OPTIONS,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
InterestFreq,
INSTRUMENT_LABELS,
accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity,
maturityAmount,
addPeriod,
@@ -173,7 +178,8 @@ interface BondSellForm {
standalone: true,
imports: [
CommonModule,
FormsModule
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class BondInvestmentComponent {
@@ -224,12 +230,17 @@ export class BondInvestmentComponent {
redeemDate = this.asOnDate;
redeemRemarks = '';
// Interest Calculation (legacy: Date / Type / Script / Deal No / ALL + Process)
intCalcDate = this.asOnDate;
// 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;
// Write-off
writeoffCertNo = '';
@@ -647,13 +658,15 @@ export class BondInvestmentComponent {
}
// -------------------- Interest calculation --------------------
/** Holdings the Interest Calculation "Process" run would cover, given the filters. */
/** 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 this.holdings.filter((h) => h.status === 'ACTIVE');
return base;
}
return this.holdings.filter((h) => {
if (h.status !== 'ACTIVE') return false;
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;
@@ -661,31 +674,62 @@ export class BondInvestmentComponent {
});
}
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];
}
}
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 accruedInterest(h, this.intCalcDate || this.asOnDate);
return accruedInterestBetween(h, this.intCalcFromDate || h.purchaseDate, this.intCalcToDate || this.asOnDate);
}
processInterest(): void {
const targets = this.intCalcTargets;
const targets = this.intCalcTargets.filter((h) => this.interestAccrued(h) > 0);
if (targets.length === 0) {
this.toast('No holdings match the selected filters.');
this.toast('No holdings have interest due to be posted for this period.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
txnDate: this.intCalcDate || this.asOnDate,
amount: this.interestAccrued(h),
particulars: `Interest calculated - ${h.scriptName}`
}));
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.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). Total ₹${total.toLocaleString('en-IN')}.`);
this.toast(
`Interest processed for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
}
// -------------------- Sell / Redeem Entry --------------------
@@ -0,0 +1,213 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, ElementRef, ViewChild, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
/**
* Drop-in replacement for `<input type="text" placeholder="DD-MM-YYYY">` used throughout the
* Investment module. Keeps the DD-MM-YYYY string every date field/helper in investment-data.ts
* already expects (parseDDMMYYYY, addPeriod, etc.), but adds a calendar button that opens the
* browser's native date picker and converts the picked value back to DD-MM-YYYY — so users can
* either type the date or pick it, without changing any consuming component's data model.
*
* Usage: <mifosx-date-ddmmyyyy [(ngModel)]="someField"></mifosx-date-ddmmyyyy>
*/
@Component({
selector: 'mifosx-date-ddmmyyyy',
standalone: true,
imports: [
CommonModule,
FormsModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateDdmmyyyyPickerComponent),
multi: true
}
],
template: `
<div class="inv-date-field">
<input
type="text"
class="inv-date-field__text"
[ngModel]="value"
(ngModelChange)="onTextChange($event)"
(blur)="onTouched()"
placeholder="DD-MM-YYYY"
[disabled]="disabled"
/>
<button
type="button"
class="inv-date-field__btn"
tabindex="-1"
[disabled]="disabled"
(click)="openPicker()"
aria-label="Pick a date"
title="Pick a date"
>
📅
</button>
<input
#native
type="date"
class="inv-date-field__native"
tabindex="-1"
[ngModel]="isoValue"
(ngModelChange)="onPickerChange($event)"
/>
</div>
`,
styles: [
`
/* Angular view encapsulation stops the host page's .inv-form-field styles from reaching
* inside this component's own template, so the look of the surrounding form fields
* (height, border, radius, font) is deliberately re-declared here instead of inherited. */
:host {
display: block;
width: 100%;
}
.inv-date-field {
position: relative;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.inv-date-field__text {
flex: 1;
min-width: 0;
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
.inv-date-field__btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
width: 36px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.inv-date-field__btn:hover:not(:disabled) {
background: #f2f4f7;
}
.inv-date-field__btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.inv-date-field__native {
position: absolute;
width: 0;
height: 0;
padding: 0;
border: none;
opacity: 0;
pointer-events: none;
}
`
]
})
export class DateDdmmyyyyPickerComponent implements ControlValueAccessor {
@ViewChild('native') private nativeInput?: ElementRef<HTMLInputElement>;
value = '';
disabled = false;
private onChangeFn: (v: string) => void = () => {};
onTouched: () => void = () => {};
/** DD-MM-YYYY -> yyyy-MM-dd, the format <input type="date"> requires. */
get isoValue(): string {
const parts = (this.value || '').split('-');
if (parts.length !== 3) {
return '';
}
const [
dd,
mm,
yyyy
] = parts;
if (!/^\d{2}$/.test(dd) || !/^\d{2}$/.test(mm) || !/^\d{4}$/.test(yyyy)) {
return '';
}
return `${yyyy}-${mm}-${dd}`;
}
writeValue(v: string): void {
this.value = v ?? '';
}
registerOnChange(fn: (v: string) => void): void {
this.onChangeFn = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
onTextChange(v: string): void {
this.value = v;
this.onChangeFn(v);
}
/** yyyy-MM-dd (from the native picker) -> DD-MM-YYYY, the format the rest of the app expects. */
onPickerChange(iso: string): void {
const parts = (iso || '').split('-');
if (parts.length !== 3) {
return;
}
const [
yyyy,
mm,
dd
] = parts;
const ddmmyyyy = `${dd}-${mm}-${yyyy}`;
this.value = ddmmyyyy;
this.onChangeFn(ddmmyyyy);
this.onTouched();
}
openPicker(): void {
const el = this.nativeInput?.nativeElement;
if (!el || this.disabled) {
return;
}
const showPicker = (el as HTMLInputElement & { showPicker?: () => void }).showPicker;
if (typeof showPicker === 'function') {
try {
showPicker.call(el);
return;
} catch {
// Some browsers throw if not called from a "user gesture" context they recognise —
// fall back to a plain click(), which still opens the native picker.
}
}
el.click();
}
}
@@ -179,8 +179,8 @@
><span>Certificate No.</span><input type="text" [(ngModel)]="purchaseForm.certNo"
/></label>
<label class="inv-form-field"
><span>Certificate Date</span><input type="text" [(ngModel)]="purchaseForm.certDate" placeholder="DD-MM-YYYY"
/></label>
><span>Certificate Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.certDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType">
@@ -198,12 +198,12 @@
</label>
<label class="inv-form-field"
><span>Transaction Date</span
><input type="text" [(ngModel)]="purchaseForm.transactionDate" placeholder="DD-MM-YYYY"
/></label>
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.transactionDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Deposit Date *</span
><input type="text" [(ngModel)]="purchaseForm.depositDate" placeholder="DD-MM-YYYY"
/></label>
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.depositDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Deposit Amount *</span><input type="number" [(ngModel)]="purchaseForm.depositAmount" min="0"
/></label>
@@ -217,8 +217,8 @@
</label>
<label class="inv-form-field"
><span>Interest Date</span
><input type="text" [(ngModel)]="purchaseForm.interestDate" placeholder="DD-MM-YYYY"
/></label>
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.interestDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Interest Rate *</span><input type="number" [(ngModel)]="purchaseForm.interestRate" min="0" step="0.01"
/></label>
@@ -235,8 +235,8 @@
} @else {
<label class="inv-form-field"
><span>Maturity Date (manual) *</span
><input type="text" [(ngModel)]="purchaseForm.manualMaturityDate" placeholder="DD-MM-YYYY"
/></label>
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.manualMaturityDate"></mifosx-date-ddmmyyyy
></label>
}
<label class="inv-form-field"
><span>Resolution No.</span><input type="text" [(ngModel)]="purchaseForm.resolutionNo"
@@ -327,11 +327,25 @@
<span>Certificate No *</span>
<select [(ngModel)]="interestCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
@for (h of interestEligibleHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="interestFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="interestToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="interestGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
@if (interestHolding; as h) {
<label class="inv-form-field"><span>Party</span><input type="text" [value]="h.party" readonly /></label>
<label class="inv-form-field"
@@ -341,7 +355,7 @@
><span>Frequency</span><input type="text" [value]="h.interestFreq" readonly
/></label>
<label class="inv-form-field"
><span>Accrued Interest (as on {{ asOnDate }})</span
><span>Interest for Period</span
><input type="text" [value]="'₹' + (interestAccrued(h) | number: '1.2-2')" readonly
/></label>
}
@@ -376,8 +390,8 @@
/></label>
}
<label class="inv-form-field"
><span>Sell / Premature Date *</span><input type="text" [(ngModel)]="sellDate" placeholder="DD-MM-YYYY"
/></label>
><span>Sell / Premature Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="sellDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"><span>Remarks</span><input type="text" [(ngModel)]="sellRemark" /></label>
</div>
<div class="inv-form-actions">
@@ -454,6 +468,7 @@
<th>Cert No</th>
<th>Type</th>
<th>Particulars</th>
<th>GL Posted</th>
<th class="num">Amount</th>
</tr>
</thead>
@@ -467,6 +482,7 @@
<td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
}
@@ -9,6 +9,7 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
FD_HOLDINGS,
ALL_TXNS,
@@ -17,10 +18,14 @@ import {
FD_TYPE_OPTIONS,
INT_TYPE_OPTIONS,
LIEN_REASON_MASTER,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
InvestmentHolding,
InvestmentTxn,
InterestFreq,
accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity,
maturityAmount,
addPeriod,
@@ -83,7 +88,8 @@ interface FdPurchaseForm {
standalone: true,
imports: [
CommonModule,
FormsModule
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class FdInvestmentComponent {
@@ -98,6 +104,7 @@ export class FdInvestmentComponent {
readonly fdTypeOptions = FD_TYPE_OPTIONS;
readonly intTypeOptions = INT_TYPE_OPTIONS;
readonly lienReasonOptions = LIEN_REASON_MASTER;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
readonly branchOptions = [
'HEAD OFFICE',
'BRANCH 1',
@@ -114,6 +121,9 @@ export class FdInvestmentComponent {
searchCertNo = '';
interestCertNo = '';
interestFromDate = addPeriod(this.asOnDate, 0, -3);
interestToDate = this.asOnDate;
interestGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
sellCertNo = '';
sellDate = this.asOnDate;
sellRemark = '';
@@ -313,12 +323,24 @@ export class FdInvestmentComponent {
}
// -------------------- Interest --------------------
/** Certificates eligible for accrual-basis posting — same rule as the Investment Interest
* module: a Non-Performing holding never appears here, only through Interest Receive. */
get interestEligibleHoldings(): InvestmentHolding[] {
return this.activeHoldings.filter((h) => canAccrueInterest(h));
}
get interestHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.interestCertNo);
}
/** Interest accrued strictly within [interestFromDate, interestToDate] — a period-based
* Interest Calculation & Post run, same basis as the Investment Interest module. */
interestAccrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate);
return accruedInterestBetween(h, this.interestFromDate || h.purchaseDate, this.interestToDate || this.asOnDate);
}
glName(glCode: string): string {
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
}
postInterest(): void {
@@ -328,19 +350,34 @@ export class FdInvestmentComponent {
return;
}
const amt = this.interestAccrued(h);
if (amt <= 0) {
this.toast('No interest due to be posted for this period.');
return;
}
const toDate = this.interestToDate || this.asOnDate;
const fromDate = this.interestFromDate || toDate;
const glCode = this.interestGlCode;
const glName = this.glName(glCode);
h.interestPosted = (h.interestPosted ?? 0) + amt;
h.interestPostedTillDate = toDate;
this.interestFromDate = toDate;
this.txns = [
{
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'INTEREST',
voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate,
txnDate: toDate,
amount: amt,
particulars: `Interest posted - ${h.party} (${h.interestFreq})`
particulars: `Interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party} (${h.interestFreq})`,
glCode,
glName
},
...this.txns
];
this.toast(`Interest of ₹${amt.toLocaleString('en-IN')} posted for Certificate ${h.certNo}.`);
this.toast(
`Interest of ₹${amt.toLocaleString('en-IN')} posted to ${glCode} - ${glName} for Certificate ${h.certNo}.`
);
this.interestCertNo = '';
}
@@ -85,10 +85,16 @@
<div class="pg-card">
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div>
<p class="pg-sub">
Govt Security, Non-SLR, Mutual Fund and Share targets trace to RBI's SLR floor (18% of NDTL) and Non-SLR ceiling
(≤10% of deposits) for Urban Co-operative Banks; Fixed Deposit and Bond are board-approved policy choices within
the room that framework leaves.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Instrument Type</th>
<th>Target Basis</th>
<th class="num">Target %</th>
<th class="num">Actual %</th>
<th class="num">Actual Value</th>
@@ -100,6 +106,11 @@
@for (a of allocationVsTarget; track a.type) {
<tr>
<td>{{ a.label }}</td>
<td>
<span class="inv-badge" [class.inv-badge--matured]="a.isRbiLinked">
{{ a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy' }}
</span>
</td>
<td class="num">{{ a.targetPct }}%</td>
<td class="num">{{ a.actualPct }}%</td>
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import {
ALL_HOLDINGS,
ALLOCATION_TARGET_IS_RBI_LINKED,
INSTRUMENT_LABELS,
InstrumentType,
InvestmentHolding,
@@ -39,6 +40,9 @@ interface AllocationRow {
actualValue: number;
variancePct: number;
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated';
/** True when the target itself traces to an RBI SLR/Non-SLR rule rather than being a
* board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment in investment-data.ts. */
isRbiLinked: boolean;
}
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never
@@ -114,7 +118,8 @@ export class InvestmentDashboardComponent {
actualPct: +actualPct.toFixed(1),
actualValue: value,
variancePct,
statusLabel
statusLabel,
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
};
});
}
@@ -195,6 +200,7 @@ export class InvestmentDashboardComponent {
subtitle: `As on ${this.asOnDate}`,
headers: [
'Instrument Type',
'Target Basis',
'Target %',
'Actual %',
'Actual Value',
@@ -203,6 +209,7 @@ export class InvestmentDashboardComponent {
],
widths: [
26,
20,
12,
12,
18,
@@ -211,6 +218,7 @@ export class InvestmentDashboardComponent {
],
rows: this.allocationVsTarget.map((a) => [
a.label,
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy',
a.targetPct,
a.actualPct,
a.actualValue,
+361 -17
View File
@@ -53,16 +53,45 @@ export const PROVISION_STATUS_LABELS: Record<InvestmentProvisionStatus, string>
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. */
/**
* 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. Four of these six trace back to real RBI rules for Urban
* Co-operative Banks (RBI Master Direction/Circular — Investment Portfolio of Primary (Urban)
* Co-operative Banks):
* • SLR: UCBs must hold 18% of NDTL in approved securities — predominantly Central/State Govt
* securities (and SLR-eligible State Development Loan bonds). A well-run treasury holds a
* safety buffer above the bare floor, so Govt Security dominates the book far beyond the 18%
* minimum — it isn't itself an upper target, just the reason this bucket is the largest.
* • Non-SLR ceiling: a UCB's Non-SLR investments must not exceed 10% of its total deposits as
* on 31 March of the previous year — a hard regulatory cap, not a board choice.
* • Mutual Funds: RBI permits UCBs to invest only in debt-oriented schemes of SEBI-registered
* mutual funds, in strictly limited amounts — equity/balanced schemes are not permitted.
* • Shares: RBI does not permit UCBs to invest in equity shares of companies as a general
* investment class; any "Share" holding is essentially the (small, largely static) shares a
* UCB holds in affiliated co-operative institutions, not a discretionary trading book.
* Fixed Deposit (treasury placements with other banks) and Bond (a mix of SLR-eligible SDL and
* Non-SLR PSU/FI bonds) have no RBI-mandated percentage of their own — their split here is a
* board-approved policy choice within the room RBI's SLR/Non-SLR framework leaves.
*/
export const TARGET_ALLOCATION_PCT: Record<InstrumentType, number> = {
GOVT_SECURITY: 55,
BOND: 18,
FD: 15,
GOVT_SECURITY: 35,
NON_SLR: 10,
BOND: 20,
MUTUAL_FUND: 12,
SHARE: 8
NON_SLR: 7,
MUTUAL_FUND: 3,
SHARE: 2
};
/** True for the instrument types whose target traces to an actual RBI rule (SLR floor / Non-SLR
* ceiling) rather than a board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment. */
export const ALLOCATION_TARGET_IS_RBI_LINKED: Record<InstrumentType, boolean> = {
GOVT_SECURITY: true,
BOND: false,
FD: false,
NON_SLR: true,
MUTUAL_FUND: true,
SHARE: true
};
export interface InvestmentHolding {
@@ -90,6 +119,10 @@ export interface InvestmentHolding {
interestPosted?: number;
/** Cumulative interest actually received in cash to date. */
interestReceived?: number;
/** DD-MM-YYYY through which interest has already been calculated & posted. accruedInterestBetween()
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
* or repeated period always yields 0 instead of double-posting the same interest. */
interestPostedTillDate?: string;
}
export interface InvestmentTxn {
@@ -100,6 +133,10 @@ export interface InvestmentTxn {
txnDate: string;
amount: number;
particulars: string;
/** GL account this entry was posted/received against — the Interest Income GL for a Post,
* the Cash/Bank GL for a Receive. Absent on older/seeded entries that predate GL selection. */
glCode?: string;
glName?: string;
}
// -------------------- Date / interest helpers --------------------
@@ -175,15 +212,6 @@ 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. */
@@ -203,6 +231,23 @@ export function suspenseInterest(h: InvestmentHolding, asOnStr: string): number
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestReceived ?? 0));
}
/** Interest accrued strictly within [fromStr, toStr], clipped to the holding's own
* purchase→maturity window — the basis for a period-based Interest Calculation & Post run
* (as opposed to accruedInterest(), which is always measured from purchase date). Returns 0
* for a Non-Performing holding, same accrual-basis rule as accruedInterest(). */
export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, toStr: string): number {
if (!canAccrueInterest(h)) {
return 0;
}
const purchase = parseDDMMYYYY(h.purchaseDate).getTime();
const maturity = parseDDMMYYYY(h.maturityDate).getTime();
const postedTill = h.interestPostedTillDate ? parseDDMMYYYY(h.interestPostedTillDate).getTime() : purchase;
const from = Math.max(parseDDMMYYYY(fromStr).getTime(), purchase, postedTill);
const to = Math.min(parseDDMMYYYY(toStr).getTime(), maturity);
const days = Math.max(0, (to - from) / (24 * 3600 * 1000));
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
}
export function formatDDMMYYYY(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
@@ -388,6 +433,68 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
},
{
certNo: '15628',
instrumentType: 'FD',
scriptCode: 'FD-CUM',
scriptName: 'Cumulative Fixed Deposit',
party: 'Zilla Sahakari Bank Ltd',
subType: 'NON SLR',
faceValue: 1800000,
purchaseRate: 100,
units: 1,
purchaseDate: '20-08-2025',
maturityDate: '20-08-2026',
interestRate: 6.8,
interestFreq: 'CUMULATIVE',
glCode: '320020',
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
},
{
certNo: '15629',
instrumentType: 'FD',
scriptCode: 'FD-MIP',
scriptName: 'Monthly Interest Payout FD',
party: 'Vidarbha Cooperative Bank',
subType: 'NON SLR',
faceValue: 2200000,
purchaseRate: 100,
units: 1,
purchaseDate: '25-09-2025',
maturityDate: '25-09-2026',
interestRate: 7.15,
interestFreq: 'MONTHLY',
glCode: '320020',
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE',
interestPosted: 143220,
interestReceived: 130800
},
{
certNo: '15630',
instrumentType: 'FD',
scriptCode: 'FD-CUM',
scriptName: 'Cumulative Fixed Deposit',
party: 'Konkan Cooperative Bank Ltd',
subType: 'NON SLR',
faceValue: 900000,
purchaseRate: 100,
units: 1,
purchaseDate: '28-10-2024',
maturityDate: '28-10-2026',
interestRate: 6.75,
interestFreq: 'YEARLY',
glCode: '320020',
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
}
];
@@ -515,6 +622,46 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070',
brokerCode: 'BRK-002',
status: 'ACTIVE'
},
{
certNo: 'GSEC-2007',
instrumentType: 'GOVT_SECURITY',
scriptCode: 'TBILL-91D',
scriptName: '91 Days Treasury Bill',
party: 'RBI - SGL',
subType: 'SLR',
faceValue: 2000000,
purchaseRate: 98.2,
units: 20000,
purchaseDate: '15-05-2026',
maturityDate: '13-08-2026',
interestRate: 6.2,
interestFreq: 'CUMULATIVE',
glCode: '310020',
glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-003',
status: 'ACTIVE'
},
{
certNo: 'GSEC-2008',
instrumentType: 'GOVT_SECURITY',
scriptCode: 'GJ-SDL-2026',
scriptName: '7.85% Gujarat SDL 2026',
party: 'RBI - SGL',
subType: 'SLR',
faceValue: 1800000,
purchaseRate: 100.2,
units: 18000,
purchaseDate: '18-09-2025',
maturityDate: '18-09-2026',
interestRate: 7.85,
interestFreq: 'HALF_YEARLY',
glCode: '310020',
glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-001',
status: 'ACTIVE'
}
];
@@ -620,6 +767,46 @@ export const NON_SLR_HOLDINGS: InvestmentHolding[] = [
status: 'ACTIVE',
interestPosted: 89400,
interestReceived: 60000
},
{
certNo: 'NSLR-3006',
instrumentType: 'NON_SLR',
scriptCode: 'IRFC-7.55-2026',
scriptName: 'IRFC Bond 7.55% 2026',
party: 'Indian Railway Finance Corp',
subType: 'NON SLR',
faceValue: 1600000,
purchaseRate: 100,
units: 16000,
purchaseDate: '25-08-2025',
maturityDate: '25-08-2026',
interestRate: 7.55,
interestFreq: 'YEARLY',
glCode: '310030',
glName: 'INVESTMENT IN NON SLR SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-004',
status: 'ACTIVE'
},
{
certNo: 'NSLR-3007',
instrumentType: 'NON_SLR',
scriptCode: 'NHB-7.3-2026',
scriptName: 'NHB Bond 7.30% 2026',
party: 'National Housing Bank',
subType: 'NON SLR',
faceValue: 1000000,
purchaseRate: 100,
units: 10000,
purchaseDate: '10-10-2025',
maturityDate: '10-10-2026',
interestRate: 7.3,
interestFreq: 'YEARLY',
glCode: '310030',
glName: 'INVESTMENT IN NON SLR SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-003',
status: 'ACTIVE'
}
];
@@ -726,6 +913,46 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070',
brokerCode: 'BRK-005',
status: 'ACTIVE'
},
{
certNo: 'BOND-4006',
instrumentType: 'BOND',
scriptCode: 'CHOLA-8.35-2026',
scriptName: 'Cholamandalam Finance NCD 8.35% 2026',
party: 'Cholamandalam Investment & Finance',
subType: 'CORPORATE BOND',
faceValue: 1400000,
purchaseRate: 100,
units: 14000,
purchaseDate: '15-09-2024',
maturityDate: '15-09-2026',
interestRate: 8.35,
interestFreq: 'YEARLY',
glCode: '310040',
glName: 'INVESTMENT IN CORPORATE BONDS',
crToAc: '310070',
brokerCode: 'BRK-006',
status: 'ACTIVE'
},
{
certNo: 'BOND-4007',
instrumentType: 'BOND',
scriptCode: 'PEL-8.4-2026',
scriptName: 'Piramal Enterprises NCD 8.40% 2026',
party: 'Piramal Enterprises Ltd',
subType: 'CORPORATE BOND',
faceValue: 900000,
purchaseRate: 100,
units: 9000,
purchaseDate: '28-10-2025',
maturityDate: '28-10-2026',
interestRate: 8.4,
interestFreq: 'YEARLY',
glCode: '310040',
glName: 'INVESTMENT IN CORPORATE BONDS',
crToAc: '310070',
brokerCode: 'BRK-005',
status: 'ACTIVE'
}
];
@@ -829,6 +1056,46 @@ export const MUTUAL_FUND_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
},
{
certNo: 'MF-5006',
instrumentType: 'MUTUAL_FUND',
scriptCode: 'SBI-BPSU',
scriptName: 'SBI Banking & PSU Fund',
party: 'SBI Mutual Fund',
subType: 'DEBT - BANKING & PSU',
faceValue: 700000,
purchaseRate: 18.6,
units: 37634.4,
purchaseDate: '10-09-2025',
maturityDate: '10-09-2026',
interestRate: 7.1,
interestFreq: 'CUMULATIVE',
glCode: '310050',
glName: 'INVESTMENT IN MUTUAL FUNDS',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
},
{
certNo: 'MF-5007',
instrumentType: 'MUTUAL_FUND',
scriptCode: 'ICICI-CB',
scriptName: 'ICICI Pru Corporate Bond Fund',
party: 'ICICI Prudential Mutual Fund',
subType: 'DEBT - CORPORATE BOND',
faceValue: 1100000,
purchaseRate: 24.8,
units: 44354.84,
purchaseDate: '14-07-2026',
maturityDate: '14-07-2027',
interestRate: 7.05,
interestFreq: 'CUMULATIVE',
glCode: '310050',
glName: 'INVESTMENT IN MUTUAL FUNDS',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
}
];
@@ -932,6 +1199,46 @@ export const SHARE_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
},
{
certNo: 'SH-6006',
instrumentType: 'SHARE',
scriptCode: 'ICICIBANK',
scriptName: 'ICICI Bank Ltd',
party: 'NSE/BSE',
subType: 'EQUITY',
faceValue: 420000,
purchaseRate: 1050,
units: 400,
purchaseDate: '05-03-2026',
maturityDate: '05-03-2036',
interestRate: 2.0,
interestFreq: 'YEARLY',
glCode: '310060',
glName: 'INVESTMENT IN SHARES',
crToAc: '310070',
brokerCode: 'BRK-007',
status: 'ACTIVE'
},
{
certNo: 'SH-6007',
instrumentType: 'SHARE',
scriptCode: 'PDCC',
scriptName: 'Pune District Central Coop Bank Shares',
party: 'Pune District Central Cooperative Bank',
subType: 'CO-OP SHARE',
faceValue: 60000,
purchaseRate: 50,
units: 1200,
purchaseDate: '18-07-2025',
maturityDate: '18-07-2035',
interestRate: 5.0,
interestFreq: 'YEARLY',
glCode: '310060',
glName: 'INVESTMENT IN SHARES',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
}
];
@@ -1060,6 +1367,43 @@ export const ALL_TXNS: InvestmentTxn[] = [
// -------------------- Masters --------------------
export interface InvestmentGlOption {
glCode: string;
glName: string;
}
/** Interest Income (Cr) GL per instrument type — the GL an Interest Calculation & Post run
* credits. Selectable on the FD/Govt./Bond Interest Calculation screens instead of being
* hard-wired, so a period's posting can be routed to a different income head when needed. */
export const INTEREST_INCOME_GL_MASTER: InvestmentGlOption[] = [
{ glCode: '410010', glName: 'INTEREST ON GOVT. SECURITIES' },
{ glCode: '410020', glName: 'INTEREST ON NON SLR SECURITIES' },
{ glCode: '410030', glName: 'INTEREST ON CORPORATE BONDS' },
{ glCode: '410040', glName: 'INTEREST ON FIXED DEPOSITS' },
{ glCode: '410050', glName: 'INCOME FROM MUTUAL FUNDS' },
{ glCode: '410060', glName: 'DIVIDEND ON SHARES' }
];
/** Default Interest Income GL suggested per instrument type — pre-fills the selector above,
* which the user can still override before posting. */
export const INTEREST_INCOME_GL_BY_TYPE: Record<InstrumentType, string> = {
GOVT_SECURITY: '410010',
NON_SLR: '410020',
BOND: '410030',
FD: '410040',
MUTUAL_FUND: '410050',
SHARE: '410060'
};
/** Cash/Bank (Dr) GL credited when an Interest Receive entry is booked — the account the cash
* actually lands in. Selectable on the Interest Receive screen. */
export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
{ glCode: '100010', glName: 'CASH IN HAND' },
{ glCode: '110010', glName: 'RBI CURRENT ACCOUNT' },
{ glCode: '110020', glName: 'SBI CURRENT ACCOUNT' },
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
];
export interface ScriptMaster {
scriptCode: string;
scriptName: string;
@@ -184,8 +184,11 @@
<div class="pos-section-label">Investment - FD Interest Calculation &amp; Post</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="fdCalcDate" placeholder="DD-MM-YYYY"
/></label>
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Certificate No</span>
<select [(ngModel)]="fdCalcCertNo">
@@ -198,6 +201,14 @@
<label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="fdCalcAll" style="height: 18px; width: 18px"
/></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="fdCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Total Interest to Post</span
><input type="text" [value]="'₹' + (fdCalcTotal | number: '1.2-2')" readonly
@@ -257,11 +268,14 @@
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation &amp; Post</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="bondCalcDate" placeholder="DD-MM-YYYY"
/></label>
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Type</span>
<select [(ngModel)]="bondCalcType">
<select [(ngModel)]="bondCalcType" (ngModelChange)="onBondCalcTypeChange()">
<option value="">-- All Types --</option>
<option value="GOVT_SECURITY">GOVT SECURITY</option>
<option value="NON_SLR">NON SLR</option>
@@ -281,6 +295,14 @@
<label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="bondCalcAll" style="height: 18px; width: 18px"
/></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="bondCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Total Interest to Post</span
><input type="text" [value]="'₹' + (bondCalcTotal | number: '1.2-2')" readonly
@@ -342,6 +364,16 @@
@if (activeTab === 'receive') {
<div class="pg-card">
<div class="pos-section-label">Interest Receive — Record Actual Cash Receipt</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Receive Into (Cash/Bank GL)</span>
<select [(ngModel)]="receiveGlCode">
@for (g of cashBankGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
@@ -396,6 +428,7 @@
<th>Voucher No</th>
<th>Cert No</th>
<th>Particulars</th>
<th>GL Credited</th>
<th class="num col-in">Amount Received</th>
</tr>
</thead>
@@ -408,17 +441,18 @@
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No interest received yet.</td>
<td colspan="6" class="pg-empty">No interest received yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="4">Total Received</td>
<td colspan="5">Total Received</td>
<td class="num">₹{{ receivedLogTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
@@ -438,6 +472,7 @@
<th>Cert No</th>
<th>Instrument</th>
<th>Particulars</th>
<th>GL Posted</th>
<th class="num col-in">Interest Amount</th>
</tr>
</thead>
@@ -451,17 +486,18 @@
<td>{{ t.certNo }}</td>
<td>{{ instrumentLabel(t.instrumentType) }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No bond interest entries recorded yet.</td>
<td colspan="7" class="pg-empty">No bond interest entries recorded yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total</td>
<td colspan="6">Total</td>
<td class="num">₹{{ bondIntRegisterTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
@@ -480,6 +516,7 @@
<th>Voucher No</th>
<th>Cert No</th>
<th>Particulars</th>
<th>GL Posted</th>
<th class="num col-in">Interest Amount</th>
</tr>
</thead>
@@ -492,17 +529,18 @@
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No FD interest entries recorded yet.</td>
<td colspan="6" class="pg-empty">No FD interest entries recorded yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="4">Total</td>
<td colspan="5">Total</td>
<td class="num">₹{{ fdIntRegisterTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
@@ -9,6 +9,7 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS,
@@ -16,6 +17,9 @@ import {
BOND_HOLDINGS,
ALL_TXNS,
SCRIPT_MASTER,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
CASH_BANK_GL_MASTER,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
@@ -23,12 +27,13 @@ import {
INSTRUMENT_LABELS,
PROVISION_STATUS_LABELS,
accruedInterest,
accruedInterestBetween,
addPeriod,
daysToMaturity,
maturityAmount,
todayDDMMYYYY,
isNpi,
canAccrueInterest,
interestToPost,
interestReceivableBalance,
suspenseInterest,
provisionRequiredInv
@@ -67,7 +72,8 @@ type InterestTab =
standalone: true,
imports: [
CommonModule,
FormsModule
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class InvestmentInterestComponent {
@@ -83,6 +89,8 @@ export class InvestmentInterestComponent {
txns: InvestmentTxn[] = [...ALL_TXNS];
readonly scriptOptions = SCRIPT_MASTER;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
readonly cashBankGlOptions = CASH_BANK_GL_MASTER;
/** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */
receiveDrafts: Record<string, number | null> = {};
@@ -92,17 +100,24 @@ export class InvestmentInterestComponent {
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
// 01 FD Interest Calculation
fdCalcDate = this.asOnDate;
// 01 FD Interest Calculation — posts interest accrued strictly within [fromDate, toDate]
fdCalcFromDate = addPeriod(this.asOnDate, 0, -3);
fdCalcToDate = this.asOnDate;
fdCalcCertNo = '';
fdCalcAll = false;
fdCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
// 02 Govt./Bond Int. Calculation
bondCalcDate = this.asOnDate;
// 02 Govt./Bond Int. Calculation — posts interest accrued strictly within [fromDate, toDate]
bondCalcFromDate = addPeriod(this.asOnDate, 0, -3);
bondCalcToDate = this.asOnDate;
bondCalcType: InstrumentType | '' = '';
bondCalcScript = '';
bondCalcDealNo = '';
bondCalcAll = false;
bondCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
// Interest Receive — Cash/Bank GL credited for the day's collection voucher
receiveGlCode = CASH_BANK_GL_MASTER[0].glCode;
// Register filters
registerFromDate = '';
@@ -184,38 +199,56 @@ export class InvestmentInterestComponent {
}
fdInterest(h: InvestmentHolding): number {
return interestToPost(h, this.fdCalcDate || this.asOnDate);
return accruedInterestBetween(h, this.fdCalcFromDate || h.purchaseDate, this.fdCalcToDate || this.asOnDate);
}
get fdCalcTotal(): number {
return this.fdCalcTargets.reduce((s, h) => s + this.fdInterest(h), 0);
}
glName(glCode: string): string {
return (
this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ??
this.cashBankGlOptions.find((g) => g.glCode === glCode)?.glName ??
glCode
);
}
processFdInterest(): void {
const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
if (targets.length === 0) {
this.toast('No FD holdings have interest due to be posted.');
this.toast('No FD holdings have interest due to be posted for this period.');
return;
}
const toDate = this.fdCalcToDate || this.asOnDate;
const fromDate = this.fdCalcFromDate || toDate;
const glCode = this.fdCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.fdInterest(h);
h.interestPosted = this.posted(h) + amt;
h.interestPostedTillDate = toDate;
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,
voucherNo: `INV/FD-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
txnDate: toDate,
amount: amt,
particulars: `FD interest posted (accrual) - ${h.party}`
particulars: `FD interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party}`,
glCode,
glName
};
});
this.fdCalcFromDate = toDate;
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.txns = [
...newTxns,
...this.txns
];
this.toast(`FD interest posted for ${targets.length} certificate(s). Total ₹${total.toLocaleString('en-IN')}.`);
this.toast(
`FD interest posted for ${targets.length} certificate(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
}
// -------------------- 02 Govt./Bond Int. Calculation --------------------
@@ -239,30 +272,44 @@ export class InvestmentInterestComponent {
}
bondInterest(h: InvestmentHolding): number {
return interestToPost(h, this.bondCalcDate || this.asOnDate);
return accruedInterestBetween(h, this.bondCalcFromDate || h.purchaseDate, this.bondCalcToDate || this.asOnDate);
}
get bondCalcTotal(): number {
return this.bondCalcTargets.reduce((s, h) => s + this.bondInterest(h), 0);
}
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
onBondCalcTypeChange(): void {
if (this.bondCalcType) {
this.bondCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.bondCalcType];
}
}
processBondInterest(): void {
const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
if (targets.length === 0) {
this.toast('No Govt/Bond holdings have interest due to be posted.');
this.toast('No Govt/Bond holdings have interest due to be posted for this period.');
return;
}
const toDate = this.bondCalcToDate || this.asOnDate;
const fromDate = this.bondCalcFromDate || toDate;
const glCode = this.bondCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.bondInterest(h);
h.interestPosted = this.posted(h) + amt;
h.interestPostedTillDate = toDate;
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,
voucherNo: `INV/BOND-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
txnDate: toDate,
amount: amt,
particulars: `Coupon/interest posted (accrual) - ${h.scriptName}`
particulars: `Coupon/interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
glCode,
glName
};
});
const total = newTxns.reduce((s, t) => s + t.amount, 0);
@@ -270,7 +317,10 @@ export class InvestmentInterestComponent {
...newTxns,
...this.txns
];
this.toast(`Interest posted for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`);
this.bondCalcFromDate = toDate;
this.toast(
`Interest posted for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
}
// -------------------- Interest Receivable (all interest-bearing types) --------------------
@@ -331,6 +381,8 @@ export class InvestmentInterestComponent {
h.interestReceived = this.received(h) + amt;
particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`;
}
const glCode = this.receiveGlCode;
const glName = this.glName(glCode);
this.txns = [
{
certNo: h.certNo,
@@ -339,11 +391,13 @@ export class InvestmentInterestComponent {
voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`,
txnDate: this.asOnDate,
amount: amt,
particulars
particulars: `${particulars} (Dr. ${glCode} - ${glName})`,
glCode,
glName
},
...this.txns
];
this.toast(`₹${amt.toLocaleString('en-IN')} interest received for ${h.certNo}.`);
this.toast(`₹${amt.toLocaleString('en-IN')} interest received into ${glCode} - ${glName} for ${h.certNo}.`);
delete this.receiveDrafts[h.certNo];
}
@@ -400,7 +454,9 @@ export class InvestmentInterestComponent {
t.certNo,
t.instrumentType,
t.particulars,
t.amount
t.amount,
t.glCode ?? '',
t.glName ?? ''
];
}
@@ -412,7 +468,9 @@ export class InvestmentInterestComponent {
'Cert No',
'Type',
'Particulars',
'Amount'
'Amount',
'GL Code',
'GL Name'
];
const txnWidths = [
14,
@@ -420,7 +478,9 @@ export class InvestmentInterestComponent {
14,
14,
42,
16
16,
12,
28
];
const one = (
sheetName: string,
@@ -501,72 +561,78 @@ export class InvestmentInterestComponent {
return sheets;
}
case 'fdCalc':
return one(
'FD Interest Calc',
'FD Interest Calculation & Post',
[
'Cert No',
'Party',
'Face Value',
'Rate',
'Frequency',
'Interest'
],
[
14,
30,
16,
10,
14,
16
],
this.fdCalcTargets.map((h) => [
h.certNo,
h.party,
h.faceValue,
h.interestRate,
h.interestFreq,
this.fdInterest(h)
])
);
return [
{
sheetName: 'FD Interest Calc',
title: 'FD Interest Calculation & Post',
subtitle: `Period ${this.fdCalcFromDate || '—'} to ${this.fdCalcToDate || this.asOnDate} · Post to ${this.fdCalcGlCode} - ${this.glName(this.fdCalcGlCode)}`,
headers: [
'Cert No',
'Party',
'Face Value',
'Rate',
'Frequency',
'Interest'
],
widths: [
14,
30,
16,
10,
14,
16
],
rows: this.fdCalcTargets.map((h) => [
h.certNo,
h.party,
h.faceValue,
h.interestRate,
h.interestFreq,
this.fdInterest(h)
])
}
];
case 'bondCalc':
return one(
'Bond Interest Calc',
'Govt./Bond Interest Calculation & Post',
[
'Cert No',
'Script',
'Type',
'Face Value',
'Rate',
'Frequency',
'Interest'
],
[
14,
30,
16,
16,
10,
14,
16
],
this.bondCalcTargets.map((h) => [
h.certNo,
h.scriptName,
this.instrumentLabel(h.instrumentType),
h.faceValue,
h.interestRate,
h.interestFreq,
this.bondInterest(h)
])
);
return [
{
sheetName: 'Bond Interest Calc',
title: 'Govt./Bond Interest Calculation & Post',
subtitle: `Period ${this.bondCalcFromDate || '—'} to ${this.bondCalcToDate || this.asOnDate} · Post to ${this.bondCalcGlCode} - ${this.glName(this.bondCalcGlCode)}`,
headers: [
'Cert No',
'Script',
'Type',
'Face Value',
'Rate',
'Frequency',
'Interest'
],
widths: [
14,
30,
16,
16,
10,
14,
16
],
rows: this.bondCalcTargets.map((h) => [
h.certNo,
h.scriptName,
this.instrumentLabel(h.instrumentType),
h.faceValue,
h.interestRate,
h.interestFreq,
this.bondInterest(h)
])
}
];
case 'receive': {
const sheets: ExportSheetSpec[] = [
{
sheetName: 'Receive Targets',
title: 'Interest Receive — Outstanding for Collection',
subtitle,
subtitle: `${subtitle} · Receive into ${this.receiveGlCode} - ${this.glName(this.receiveGlCode)}`,
headers: [
'Cert No',
'Script / Party',
@@ -267,8 +267,8 @@
><span>Contact No</span><input type="text" [(ngModel)]="newBroker.contactNo"
/></label>
<label class="inv-form-field"
><span>Empanelled On</span><input type="text" [(ngModel)]="newBroker.empanelledOn" placeholder="DD-MM-YYYY"
/></label>
><span>Empanelled On</span><mifosx-date-ddmmyyyy [(ngModel)]="newBroker.empanelledOn"></mifosx-date-ddmmyyyy
></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addBroker()">Save</button>
@@ -349,8 +349,8 @@
<div class="pos-section-label">Add Daily Script Rate</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Rate Date *</span><input type="text" [(ngModel)]="newDailyRate.rateDate" placeholder="DD-MM-YYYY"
/></label>
><span>Rate Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="newDailyRate.rateDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Script Code *</span>
<select [(ngModel)]="newDailyRate.scriptCode">
@@ -9,6 +9,7 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
SCRIPT_MASTER,
BROKER_MASTER,
@@ -60,7 +61,8 @@ type MasterTab =
standalone: true,
imports: [
CommonModule,
FormsModule
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class InvestmentMastersComponent {
@@ -138,8 +138,8 @@
/></label>
<label class="inv-form-field"
><span>Purchase Date *</span
><input type="text" [(ngModel)]="purchaseForm.purchaseDate" placeholder="DD-MM-YYYY"
/></label>
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.purchaseDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode"
/></label>
@@ -9,6 +9,7 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data';
import { exportWorkbook } from '../investment-export';
@@ -41,7 +42,8 @@ interface SharePurchaseForm {
standalone: true,
imports: [
CommonModule,
FormsModule
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class ShareInvestmentComponent {
@@ -1470,16 +1470,30 @@ export function branchTargetAmount(branchCode: string): number {
return BRANCH_DISBURSEMENT_TARGET[branchCode] || 0;
}
/** Target product mix (% of total disbursement) — weighted towards secured Housing/Vehicle
* lending plus the priority-sector KCC/Cash Credit book, adding up to 100%. */
/**
* Target product mix (% of total disbursement). Two of these are genuinely RBI-mandated
* Priority Sector Lending sub-targets (RBI Master Direction — Priority Sector Lending: Targets
* and Classification, FIDD.CO.Plan.BC.13/04.09.01/2020-21, as extended to UCBs) —
* • Agriculture: 18% of Adjusted Net Bank Credit (ANBC) — mapped here to Kisan Credit Card.
* • Micro Enterprises: 7.5% of ANBC — mapped here to Cash Credit (MSME).
* RBI sets these as a % of the BANK'S WHOLE loan book (ANBC), not of any one product line's own
* disbursement — shown here against this module's own mix as the closest available proxy, since
* this demo has no bank-wide ANBC figure to check against. The remaining five products (Housing,
* Vehicle, Gold, Personal, Education) have no RBI-mandated minimum share of their own — Housing
* and Education are Priority-Sector-*eligible* within RBI's loan-amount ceilings but aren't
* separately targeted, and Vehicle/Gold/Personal aren't Priority Sector categories at all — so
* their split below is an internal business-plan assumption, not a regulatory requirement.
* (RBI's other PSL sub-targets — Weaker Sections 12%, overall PSL 40% — cut across products by
* borrower category rather than by loan type, so they aren't represented as their own row here.)
*/
export const PRODUCT_TARGET_PCT: Record<string, number> = {
PL: 12,
HL: 28,
HL: 30,
VL: 15,
GL: 10,
CC: 15,
KCC: 12,
EL: 8
GL: 12,
CC: 7.5, // RBI Micro Enterprises PSL sub-target
KCC: 18, // RBI Agriculture PSL sub-target
EL: 5.5
};
/** Target channel mix (% of disbursement) — the bank's push towards BC/Digital origination and
@@ -483,10 +483,17 @@
<div class="pg-card">
<div class="pos-section-label">Product Mix — Target % vs Actual % (of Disbursed Amount)</div>
<p class="pg-sub npa-report-note">
KCC and Cash Credit (MSME) targets are RBI's actual Priority Sector Lending sub-targets — Agriculture 18% and
Micro Enterprises 7.5% of Adjusted Net Bank Credit (shown here against this module's own mix, since RBI sets
them against the bank's whole loan book, not one product line). The rest are internal business-plan targets, not
RBI-mandated.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th>Target Basis</th>
<th class="num">Target %</th>
<th class="num col-in">Actual %</th>
<th class="num">Variance</th>
@@ -497,7 +504,12 @@
@for (row of productTargetRows; track row.code) {
<tr>
<td>{{ row.label }}</td>
<td class="num">{{ row.targetPct | number: '1.0-0' }}%</td>
<td>
<span class="loan-badge" [class.loan-badge--progress]="row.isRbiMandated">
{{ row.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target' }}
</span>
</td>
<td class="num">{{ row.targetPct | number: '1.0-1' }}%</td>
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
@@ -533,7 +545,7 @@
@for (row of channelTargetRows; track row.code) {
<tr>
<td>{{ row.label }}</td>
<td class="num">{{ row.targetPct | number: '1.0-0' }}%</td>
<td class="num">{{ row.targetPct | number: '1.0-1' }}%</td>
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
@@ -71,6 +71,9 @@ interface MixTargetRow {
actualPct: number;
variancePct: number;
status: TargetStatus;
/** True only for the two rows whose target is an actual RBI PSL sub-target (Agriculture 18% /
* Micro Enterprises 7.5% of ANBC) rather than an internal business-plan assumption. */
isRbiMandated: boolean;
}
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target';
@@ -389,7 +392,8 @@ export class LoanPipelineComponent implements OnInit {
targetPct,
actualPct,
variancePct: actualPct - targetPct,
status: targetStatus(actualPct, targetPct, 3)
status: targetStatus(actualPct, targetPct, 3),
isRbiMandated: p.code === 'KCC' || p.code === 'CC'
};
});
}
@@ -413,7 +417,8 @@ export class LoanPipelineComponent implements OnInit {
targetPct,
actualPct,
variancePct: actualPct - targetPct,
status: targetStatus(actualPct, targetPct, 3)
status: targetStatus(actualPct, targetPct, 3),
isRbiMandated: false
};
});
}
@@ -626,6 +631,7 @@ export class LoanPipelineComponent implements OnInit {
subtitle,
headers: [
'Mix',
'Target Basis',
'Target %',
'Actual %',
'Variance (pp)',
@@ -633,6 +639,7 @@ export class LoanPipelineComponent implements OnInit {
],
widths: [
26,
18,
12,
12,
14,
@@ -641,6 +648,7 @@ export class LoanPipelineComponent implements OnInit {
rows: [
...this.productTargetRows.map((r) => [
`Product — ${r.label}`,
r.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target',
r.targetPct,
Number(r.actualPct.toFixed(1)),
Number(r.variancePct.toFixed(1)),
@@ -648,6 +656,7 @@ export class LoanPipelineComponent implements OnInit {
]),
...this.channelTargetRows.map((r) => [
`Channel — ${r.label}`,
'Internal Target',
r.targetPct,
Number(r.actualPct.toFixed(1)),
Number(r.variancePct.toFixed(1)),
@@ -264,16 +264,22 @@
<div class="pos-section-label">Record a Recovery Against an NPA Account</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>* NPA Account</span>
<select [(ngModel)]="newRecovery.accountNo">
<option value="">-- Select --</option>
@for (a of recoveryOptions; track a.accountNo) {
<option [value]="a.accountNo">
{{ a.accountNo }} — {{ a.customerName }} ({{ stageLabel(stage(a)) }})
</option>
<span>Account Type</span>
<select [ngModel]="recoveryProductFilter" (ngModelChange)="setRecoveryProductFilter($event)">
<option value="">-- All Account Types --</option>
@for (p of recoveryProductOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* NPA Account</span>
<mifosx-search-select
[options]="recoveryAccountSearchOptions"
[(ngModel)]="newRecovery.accountNo"
placeholder="Search account no. or customer name…"
></mifosx-search-select>
</label>
<label class="inv-form-field">
<span>* Recovery Date</span>
<input type="text" [(ngModel)]="newRecovery.date" placeholder="DD-MM-YYYY" />
@@ -299,45 +305,66 @@
</div>
<div class="pg-card">
<div class="pos-section-label">
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
gnpaAmount | number: '1.0-0'
}}
GNPA outstanding
<div
class="pos-section-label"
style="display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap"
>
<span>
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
gnpaAmount | number: '1.0-0'
}}
GNPA outstanding
</span>
@if (recoveryProductFilter) {
<span class="pg-chip">Filtered: {{ recoveryProductFilter }}</span>
}
</div>
<p class="pg-sub" style="padding: 8px 16px 12px">
Target Recovery is the account's contractual due this month (EMI / interest-servicing / bullet amount) — same
basis as the Recovery Management Dashboard.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Account Type</th>
<th>NPA Category</th>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Mode</th>
<th class="num col-in">Recovered Amount</th>
<th class="num">Target Recovery</th>
<th class="num col-in">Recovered Amount (Actual)</th>
</tr>
</thead>
<tbody>
@for (r of paged(recoveryRows); track r.accountNo + r.date + r.amount) {
<tr>
<td>{{ r.date }}</td>
<td>{{ r.productName }}</td>
<td>
<span [class]="r.npaCategoryClass">{{ r.npaCategory }}</span>
</td>
<td>
<span class="pg-chip">{{ r.accountNo }}</span>
</td>
<td>{{ r.customerName }}</td>
<td>{{ r.branchName }}</td>
<td>{{ r.mode }}</td>
<td class="num">₹{{ r.targetRecovery | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No recoveries recorded yet.</td>
<td colspan="9" class="pg-empty">No recoveries recorded yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total Recovered</td>
<td class="num">₹{{ totalRecoveredAmount | number: '1.0-0' }}</td>
<td colspan="7">Total (Target once per account · Actual across all entries shown)</td>
<td class="num">₹{{ recoveryTargetTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ recoveryActualTotal | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
@@ -14,6 +14,7 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
import { SearchSelectComponent, SearchSelectOption } from '../search-select/search-select.component';
import {
LOAN_ACCOUNTS,
BRANCHES,
@@ -79,9 +80,16 @@ interface RecoveryRow {
accountNo: string;
customerName: string;
branchName: string;
productName: string;
npaCategory: string;
npaCategoryClass: string;
date: string;
amount: number;
mode: string;
/** What the account was contractually due to pay this month (EMI / interest-servicing /
* bullet), the same "Target" basis used by the Recovery Management Dashboard — shown
* alongside the actual recovered amount so a desk can see over/under-achievement per entry. */
targetRecovery: number;
}
interface RepaymentTypeSummaryRow {
@@ -119,7 +127,8 @@ interface AgeingBucketRow {
CommonModule,
FormsModule,
RouterModule,
FontAwesomeModule
FontAwesomeModule,
SearchSelectComponent
]
})
export class NpaReportsComponent implements OnInit, OnDestroy {
@@ -457,25 +466,75 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
return this.npaAccounts.filter((a) => a.recoveries.length > 0);
}
/** '' = All Account Types. One shared filter drives both the "NPA Account" picker above (so
* it's quicker to find the right account when adding a new entry) and the Recovery Register
* below — a single control instead of two that could drift out of sync. */
recoveryProductFilter = '';
get recoveryProductOptions(): string[] {
return [...new Set(this.npaAccounts.map((a) => a.productName))].sort();
}
setRecoveryProductFilter(productName: string): void {
this.recoveryProductFilter = productName;
this.currentPage = 1;
if (!this.recoveryOptions.some((a) => a.accountNo === this.newRecovery.accountNo)) {
this.newRecovery.accountNo = '';
}
}
/** NPA accounts selectable in "Record a Recovery", narrowed by the Account Type filter. */
get recoveryOptions(): LoanAccount[] {
return this.npaAccounts;
return this.npaAccounts.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter);
}
/** recoveryOptions, reshaped for the searchable NPA Account picker — matches on account no,
* customer name and branch, not just the collapsed label text. */
get recoveryAccountSearchOptions(): SearchSelectOption[] {
return this.recoveryOptions.map((a) => ({
value: a.accountNo,
label: `${a.accountNo} — ${a.customerName} (${this.stageLabel(this.stage(a))})`,
searchText: `${a.accountNo} ${a.customerName} ${a.productName} ${a.branchName}`
}));
}
get recoveryRows(): RecoveryRow[] {
return this.recoveryAccounts
.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter)
.flatMap((a) =>
a.recoveries.map((r) => ({
accountNo: a.accountNo,
customerName: a.customerName,
branchName: a.branchName,
productName: a.productName,
npaCategory: this.stageLabel(this.stage(a)),
npaCategoryClass: this.badgeClass(this.stage(a)),
date: r.date,
amount: r.amount,
mode: r.mode
mode: r.mode,
targetRecovery: expectedRecoveryThisMonth(a)
}))
)
.sort((a, b) => b.amount - a.amount);
}
/** The accounts behind recoveryRows, honouring the Account Type filter — used to total the
* monthly Target once per account rather than once per recovery transaction (an account with
* two recovery entries must not have its target counted twice). */
get recoveryFilteredAccounts(): LoanAccount[] {
return this.recoveryAccounts.filter(
(a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter
);
}
get recoveryTargetTotal(): number {
return this.recoveryFilteredAccounts.reduce((s, a) => s + expectedRecoveryThisMonth(a), 0);
}
get recoveryActualTotal(): number {
return this.recoveryRows.reduce((s, r) => s + r.amount, 0);
}
// -------------------- Portfolio-wide KPIs (shown on every screen) --------------------
get totalAdvances(): number {
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
@@ -616,8 +675,11 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
}
// -------------------- NPA Summary Report: stage-wise + branch-wise cuts --------------------
/** SMA-0/1/2 are early-warning "special mention" buckets, not NPA stages — this report only
* shows Standard vs the actual NPA classification ladder (Sub-Standard through Loss). SMA
* accounts still have their own dedicated Day-wise Overdue report. */
get stageSummary(): StageSummaryRow[] {
return STAGE_ORDER.map((stage) => {
return STAGE_ORDER.filter((stage) => !isSmaStage(stage)).map((stage) => {
const accts = this.allAccounts.filter((a) => this.stage(a) === stage);
return {
stage,
@@ -649,15 +711,15 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
}
get grandTotalAccounts(): number {
return this.allAccounts.length;
return this.stageSummary.reduce((s, row) => s + row.count, 0);
}
get grandTotalOutstanding(): number {
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
return this.stageSummary.reduce((s, row) => s + row.outstanding, 0);
}
get grandTotalProvision(): number {
return this.allAccounts.reduce((s, a) => s + this.provisionReq(a), 0);
return this.stageSummary.reduce((s, row) => s + row.provisionReq, 0);
}
get bankWideTotalAccounts(): number {
@@ -865,39 +927,53 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
{
sheetName: 'NPA Recovery',
title: 'NPA Recovery Register',
subtitle,
subtitle: this.recoveryProductFilter
? `${subtitle} · Account Type: ${this.recoveryProductFilter}`
: subtitle,
headers: [
'Date',
'Account Type',
'NPA Category',
'Account No',
'Customer',
'Branch',
'Mode',
'Recovered Amount'
'Target Recovery',
'Recovered Amount (Actual)'
],
widths: [
12,
18,
14,
16,
22,
16,
18,
18
16,
20
],
rows: [
...this.recoveryRows.map((r) => [
r.date,
r.productName,
r.npaCategory,
r.accountNo,
r.customerName,
r.branchName,
r.mode,
r.targetRecovery,
r.amount
]),
[
'Total Recovered',
'Total (Target once per account · Actual across all entries)',
'',
'',
'',
'',
this.totalRecoveredAmount
'',
'',
this.recoveryTargetTotal,
this.recoveryActualTotal
]
]
}
@@ -0,0 +1,237 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
export interface SearchSelectOption {
value: string;
label: string;
/** What typed text is matched against — defaults to `label` when omitted, but a caller can
* pass a richer string (e.g. account no + customer name + branch) so a search hits fields
* that aren't all visible in the collapsed label. */
searchText?: string;
}
/**
* Drop-in replacement for a plain `<select>` when the option list is long enough that scanning
* it is impractical (e.g. hundreds of loan accounts) — a text box that filters the option list
* as you type, instead of a native dropdown you can only scroll through. Selection only commits
* on click; typing without picking an option reverts the box to the last committed selection on
* blur, so the bound ngModel value never drifts from an actual, valid option.
*
* The option list is positioned `fixed` and anchored to the input's own on-screen coordinates
* (recomputed on open/scroll/resize) rather than `absolute` inside the component — most of this
* app's cards use `overflow: hidden` for rounded corners, which would otherwise clip a dropdown
* that extends past the card's edge.
*
* Usage: <mifosx-search-select [options]="opts" [(ngModel)]="selectedValue"></mifosx-search-select>
*/
@Component({
selector: 'mifosx-search-select',
standalone: true,
imports: [
CommonModule,
FormsModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SearchSelectComponent),
multi: true
}
],
template: `
<div class="inv-search-select">
<input
#inputEl
type="text"
class="inv-search-select__input"
[value]="query"
[placeholder]="placeholder"
[disabled]="disabled"
(input)="onInput($event)"
(focus)="onFocus()"
(blur)="onBlur()"
/>
@if (open) {
<ul
class="inv-search-select__list"
[style.top.px]="listTop"
[style.left.px]="listLeft"
[style.width.px]="listWidth"
(mousedown)="$event.preventDefault()"
>
@for (o of filteredOptions; track o.value) {
<li
class="inv-search-select__option"
[class.inv-search-select__option--active]="o.value === value"
(click)="selectOption(o)"
>
{{ o.label }}
</li>
} @empty {
<li class="inv-search-select__empty">No matches</li>
}
</ul>
}
</div>
`,
styles: [
`
/* Angular view encapsulation stops the host page's shared styles from reaching inside this
* component's own template, so its look is re-declared here rather than inherited. */
:host {
display: block;
width: 100%;
}
.inv-search-select {
position: relative;
width: 100%;
}
.inv-search-select__input {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
.inv-search-select__list {
position: fixed;
z-index: 2000;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18);
}
.inv-search-select__option {
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
color: #0d1b2a;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inv-search-select__option:hover,
.inv-search-select__option--active {
background: #eef2ff;
}
.inv-search-select__empty {
padding: 8px 10px;
font-size: 12px;
color: #94a3b8;
}
`
]
})
export class SearchSelectComponent implements ControlValueAccessor {
@Input() options: SearchSelectOption[] = [];
@Input() placeholder = 'Search…';
@ViewChild('inputEl') private inputEl?: ElementRef<HTMLInputElement>;
value = '';
query = '';
open = false;
disabled = false;
listTop = 0;
listLeft = 0;
listWidth = 0;
private onChangeFn: (v: string) => void = () => {};
onTouched: () => void = () => {};
get filteredOptions(): SearchSelectOption[] {
const q = this.query.trim().toLowerCase();
if (!q) {
return this.options;
}
return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q));
}
private labelFor(value: string): string {
return this.options.find((o) => o.value === value)?.label ?? '';
}
writeValue(v: string): void {
this.value = v ?? '';
this.query = this.labelFor(this.value);
}
registerOnChange(fn: (v: string) => void): void {
this.onChangeFn = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
/** Anchors the fixed-position list to the input's current on-screen box. */
private updateListPosition(): void {
const el = this.inputEl?.nativeElement;
if (!el) {
return;
}
const rect = el.getBoundingClientRect();
this.listTop = rect.bottom + 4;
this.listLeft = rect.left;
this.listWidth = rect.width;
}
@HostListener('window:scroll')
@HostListener('window:resize')
onWindowChange(): void {
if (this.open) {
this.updateListPosition();
}
}
onInput(event: Event): void {
this.query = (event.target as HTMLInputElement).value;
this.open = true;
this.updateListPosition();
}
onFocus(): void {
this.open = true;
this.updateListPosition();
}
onBlur(): void {
this.open = false;
this.onTouched();
// Discard any uncommitted typed text — only a clicked option ever changes `value`.
this.query = this.labelFor(this.value);
}
selectOption(o: SearchSelectOption): void {
this.value = o.value;
this.query = o.label;
this.open = false;
this.onChangeFn(this.value);
this.onTouched();
}
}