Compare commits

...
Author SHA1 Message Date
anshul b3f2addb5c feat(investment): share investment screens on live data 2026-10-28 17:41:00 +05:30
anshul ad0a1a0b1d feat(investment): show sent-for-approval message on investment actions 2026-10-27 17:21:33 +05:30
anshulandClaude Opus 5.5 d26cb89c5a feat(investment): MTM valuation and mutual fund switch screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:03:59 +05:30
anshulandClaude Opus 5.5 13d04353e1 feat(investment): non-SLR bonds and mutual funds, rate upload, UAT fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:14:41 +05:30
anshul 8e98077158 feat: add investment reports, bond components, dashboard, and service implementation 2026-10-23 16:49:50 +05:30
anshul 731ab5a0e0 feat(investment): bucket wise report, script rating and non-SLR limits on purchase 2026-10-23 12:38:24 +05:30
anshul 7bd880387e feat(investment): interest received and receivable report page with Excel export 2026-10-23 11:26:11 +05:30
anshul 86e3a5da27 Merge remote-tracking branch 'origin/dev-latest' into feature/investment 2026-10-22 18:32:50 +05:30
anshul d89f13471c feat(investment): due date diary, script status, deal no by deal date, matured-only redemption, keep typed rate and business-rule error title 2026-10-22 17:39:50 +05:30
anshul 6890584b16 feat(investment): FD TDS fields, GOI interest to the paisa, compact home reminders, deal no in holdings and settle guard 2026-10-20 16:16:12 +05:30
anshul d584af8e54 feat(investment): FD screens - purchase, maturity/premature/renewal, interest entry and reminders 2026-10-16 17:24:13 +05:30
anshul e1b33827ea fix(investment): maturity date mandatory on script master 2026-10-16 12:11:49 +05:30
anshul 8ed8f92b00 feat(investment): sell, redemption, write-off, GOI transfer and amortize screens 2026-10-15 18:45:21 +05:30
anshul c9feeae3cb feat(investment): interest in transaction log, accrued interest since last coupon, investment-only GL list 2026-10-14 17:40:08 +05:30
anshul 0404197c1b feat(investment): interest voucher posting UI, home pending alert and day-end reminder 2026-10-14 15:11:43 +05:30
anshul 8a6becff86 Merge origin/dev-latest into feature/investment 2026-10-14 10:49:37 +05:30
anshulandClaude Opus 5.5 c803e20628 feat(investment): settle purchases from Holdings; broken-period interest to settlement date
- Purchases are saved as Pending Settlement. Holdings show the status and
  a Settle button (from the settlement date), and the GL transaction id
  once settled.
- The transaction log shows "Not posted (pending settlement)" until a
  deal is settled, and dates the purchase on its settlement date.
- Broken-period interest is counted up to the settlement date.
- Accounting Preview title and the Holdings Settlement Date column
  reflect settlement-date posting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:28:19 +05:30
anshulandClaude Opus 5.5 6a04a92113 feat(investment): Daily Script Master, market rate on purchase, broken-period interest as expense
- 09 Daily Script Master now saves to and loads from the backend (sample
  rows removed), with Drop to correct a wrong rate.
- Purchase Entry takes the rate (NAV for a mutual fund) from the newest
  Daily Script Master rate on or before the deal date, shows which rate
  was used, warns when the rate entered is outside the day's low-high,
  and clears the rate when the script has none.
- Broken-period interest paid to the seller is debited to expense GL
  500606 instead of Interest Receivable (RBI UCB Investment Directions,
  para 152, confirmed by Jitendra Sir); Interest Entry labels it so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:27:15 +05:30
anshulandClaude Opus 5.5 88f34f58e3 feat(investment): deal time, interest cycle and charges on Purchase Entry
As per Jitendra Sir's review:
- Deal Time is now a mandatory field next to Deal Date.
- New Interest Cycle section: the two coupon days, last and next coupon,
  next coupon amount and days to it.
- Stamp duty, brokerage and other charges sit on the main form. They are
  expenses on their own GLs (500601/500602/500603), so they are added to
  the settlement amount but no longer to the book value, and each shows
  as its own debit line in the accounting preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:54:33 +05:30
anshul 88c4242692 Merge origin/dev-latest into feature/investment 2026-10-12 10:35:53 +05:30
anshulandClaude Opus 5.5 ca01e9b65b feat(investment): Interest Entry screen with coupon and provision schedule
Replace the dummy interest calculation tab with the real Interest Entry:
pick Type / Script / Deal No and Retrieve the holding's schedule from
the backend - every half-yearly coupon and every 31 Mar / 30 Sep closing
provision up to maturity, with days (30/360), gross interest, amount
already booked, net income and the Dr/Cr entry per line. Display only;
nothing is posted to GL yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:36:01 +05:30
anshulandClaude Opus 5.5 a4e0a22205 feat(investment): wire Bank/Party, Script Master and Purchase Entry to the backend
- Bank / Party Master and Script Master follow the bank's existing CBS
  screens (address, bank type, GL A/c, RBI code, limit, LEI, counterparty
  bank details; SLR type, auto script code, ISIN, interest rate, issue /
  maturity date, auto period, company details) and save to
  /investment/parties and /investment/scripts. GL dropdowns list enabled
  ASSET detail accounts from the chart of accounts.
- Purchase Entry simplified to the mandatory fields; script details,
  settlement date, last interest date, units, book value, premium/discount,
  accrued interest (30/360 up to the deal date), settlement amount and YTM
  are filled automatically; optional fields sit under "More Details";
  an Accounting Preview shows the GL entry. Save / Retrieve / Drop use
  /investment/purchases and the Holdings register lists saved purchases.
- New InvestmentService for the investment API calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:38:10 +05:30
29 changed files with 10124 additions and 2929 deletions
@@ -386,6 +386,38 @@
</div> </div>
</div> </div>
} }
<!-- Investment interest vouchers that are due but not posted. A reminder only: unlike the interest
above it never stops the close (Jitendra Sir); the voucher can still be posted afterwards. -->
@if (preview?.investmentInterestPending?.length) {
<div class="bd-warn">
<mat-icon>notifications_active</mat-icon>
<div>
<strong>Reminder: investment interest vouchers are pending.</strong>
@for (t of preview.investmentInterestPending; track t.purchaseId) {
<div>
{{ t.lineType === 'COUPON' ? 'Coupon' : 'Provision' }} · {{ t.scriptDesc }} · deal {{ t.dealNo }} ·
{{ t.intDate }} · ₹{{ (t.lineType === 'COUPON' ? t.grossInterest : t.amount) | number: '1.2-2' }}
</div>
}
Post them under Investment → Govt Security / Bond → 03 Interest Entry. You can still close the day.
</div>
</div>
}
@if (preview?.investmentFdPending?.length) {
<div class="bd-warn">
<mat-icon>notifications_active</mat-icon>
<div>
<strong>Reminder: FD investment entries are pending.</strong>
@for (t of preview.investmentFdPending; track t.fdId) {
<div>
{{ t.lineType === 'MATURITY' ? 'Maturity' : t.lineType === 'PAYOUT' ? 'Interest payout' : 'Provision' }}
· {{ t.partyName }} · FD {{ t.dealNo }} · {{ t.intDate }} · ₹{{ t.amount | number: '1.2-2' }}
</div>
}
Post them under Investment → FD Investment. You can still close the day.
</div>
</div>
}
@if (preview?.savingsInterestPending?.length) { @if (preview?.savingsInterestPending?.length) {
<div class="bd-warn"> <div class="bd-warn">
<mat-icon>warning</mat-icon> <mat-icon>warning</mat-icon>
@@ -109,7 +109,15 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
} else if (status === 400) { } else if (status === 400) {
alert = { type: 'Bad Request', message: errorMessage || 'Invalid parameters were passed in the request!' }; alert = { type: 'Bad Request', message: errorMessage || 'Invalid parameters were passed in the request!' };
} else if (status === 403) { } else if (status === 403) {
alert = { type: 'Unauthorized Request', message: errorMessage || 'You are not authorized for this request!' }; // A 403 is either a missing permission or a business rule the record does not meet yet ("post the coupon
// first", "matures on …"). Only the first is an authorisation problem; the rule is told by its code.
const errorCode: string =
errorBody?.errors?.[0]?.userMessageGlobalisationCode || errorBody?.userMessageGlobalisationCode || '';
const isPermission = !errorCode || /not\.authori[sz]ed|unauthori[sz]ed|access\.denied|forbidden/i.test(errorCode);
alert = {
type: isPermission ? 'Unauthorized Request' : 'Action Not Allowed',
message: errorMessage || 'You are not authorized for this request!'
};
} else if (status === 404) { } else if (status === 404) {
alert = { alert = {
type: this.translate.instant('error.resource.not.found'), type: this.translate.instant('error.resource.not.found'),
+6 -6
View File
@@ -1522,13 +1522,13 @@
<!-- Investment --> <!-- Investment -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Investment</div> <div class="deposit-group-title">Investment</div>
<button mat-menu-item routerLink="/investment/dashboard" tabindex="0"> <button mat-menu-item routerLink="/investment/diary" tabindex="0">
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment <fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
</button> </button>
<button mat-menu-item routerLink="/investment/reports" tabindex="0"> <button mat-menu-item routerLink="/investment/interest-report" tabindex="0">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received &amp; Receivable Report <fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received &amp; Receivable Report
</button> </button>
<button mat-menu-item routerLink="/investment/reports" tabindex="0"> <button mat-menu-item routerLink="/investment/bucket-report" tabindex="0">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment <fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
</button> </button>
<button mat-menu-item routerLink="/deaf" tabindex="0"> <button mat-menu-item routerLink="/deaf" tabindex="0">
@@ -2392,7 +2392,7 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Due Date Diary for All Investment')" [class.hidden]="!matchesInvestmentSearch('Due Date Diary for All Investment')"
routerLink="/investment/dashboard" routerLink="/investment/diary"
(click)="investmentMenuOpen = false" (click)="investmentMenuOpen = false"
> >
<fa-icon icon="calendar-check" class="gl-icon"></fa-icon> Due Date Diary for All Investment <fa-icon icon="calendar-check" class="gl-icon"></fa-icon> Due Date Diary for All Investment
@@ -2400,7 +2400,7 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')" [class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')"
routerLink="/investment/reports" routerLink="/investment/bucket-report"
(click)="investmentMenuOpen = false" (click)="investmentMenuOpen = false"
> >
<fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment <fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment
@@ -2500,7 +2500,7 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Total Int Received & Receivable Report')" [class.hidden]="!matchesInvestmentSearch('Total Int Received & Receivable Report')"
routerLink="/investment/reports" routerLink="/investment/interest-report"
(click)="investmentMenuOpen = false" (click)="investmentMenuOpen = false"
> >
<fa-icon icon="hand-holding-usd" class="gl-icon"></fa-icon> Total Int Received &amp; Receivable Report <fa-icon icon="hand-holding-usd" class="gl-icon"></fa-icon> Total Int Received &amp; Receivable Report
+59
View File
@@ -47,6 +47,65 @@
</div> </div>
</div> </div>
<!-- Pending investment entries (GOI / bond interest, FDs with other banks): one line each, details on demand -->
@if (pendingInvestmentInterest.length || pendingFd.length) {
<div class="investment-interest-alert">
<span class="iia-icon">🔔</span>
<div class="iia-text">
<strong>Investment entries pending</strong>
@if (pendingInvestmentInterest.length) {
<div class="iia-row">
<span class="iia-summary"
>GOI / Bonds: {{ pendingInvestmentInterest.length }} holding(s) with interest pending{{
overdueNote(pendingInvestmentInterest)
}}</span
>
<button type="button" class="iia-toggle" (click)="showInterestDetails = !showInterestDetails">
{{ showInterestDetails ? 'Hide' : 'Details' }}
</button>
<a class="iia-action" routerLink="/investment/bonds" [queryParams]="{ tab: 'interest' }"
>Open Interest Entry →</a
>
</div>
@if (showInterestDetails) {
@for (t of pendingInvestmentInterest.slice(0, detailLimit); track t.purchaseId) {
<a class="iia-item" routerLink="/investment/bonds" [queryParams]="{ tab: 'interest' }">
{{ t.lineType === 'COUPON' ? 'Coupon' : 'Provision' }} · {{ t.scriptDesc }} · deal {{ t.dealNo }} ·
{{ t.overdue ? 'overdue since ' + t.intDate : t.postable ? 'due today' : 'due ' + t.intDate }}
</a>
}
@if (pendingInvestmentInterest.length > detailLimit) {
<span class="iia-more">+{{ pendingInvestmentInterest.length - detailLimit }} more in Interest Entry</span>
}
}
}
@if (pendingFd.length) {
<div class="iia-row">
<span class="iia-summary"
>FD with banks: {{ pendingFd.length }} FD(s) with entries pending{{ overdueNote(pendingFd) }}</span
>
<button type="button" class="iia-toggle" (click)="showFdDetails = !showFdDetails">
{{ showFdDetails ? 'Hide' : 'Details' }}
</button>
<a class="iia-action" routerLink="/investment/fd">Open FD Investment →</a>
</div>
@if (showFdDetails) {
@for (t of pendingFd.slice(0, detailLimit); track t.fdId + t.lineType + t.intDate) {
<a class="iia-item" routerLink="/investment/fd" [queryParams]="{ tab: 'interest', fd: t.fdId }">
{{ t.lineType === 'MATURITY' ? 'Maturity' : t.lineType === 'PAYOUT' ? 'Interest payout' : 'Provision' }}
· {{ t.partyName }} · FD {{ t.dealNo }} ·
{{ t.overdue ? 'overdue since ' + t.intDate : t.postable ? 'due today' : 'due ' + t.intDate }}
</a>
}
@if (pendingFd.length > detailLimit) {
<span class="iia-more">+{{ pendingFd.length - detailLimit }} more in FD Investment</span>
}
}
}
</div>
</div>
}
<!-- Quick Action Cards --> <!-- Quick Action Cards -->
<div class="quick-actions"> <div class="quick-actions">
<a class="qa-card" [routerLink]="['/dashboard']" #buttonDashboard> <a class="qa-card" [routerLink]="['/dashboard']" #buttonDashboard>
+75
View File
@@ -424,3 +424,78 @@ $accent: #0ea5e9;
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.investment-interest-alert {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 12px 0;
padding: 12px 16px;
border: 1px solid #f5c26b;
border-left: 4px solid #e08e0b;
border-radius: 8px;
background: #fff8e6;
color: #5c3d00;
text-decoration: none;
.iia-icon {
font-size: 20px;
line-height: 1.2;
}
.iia-text {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.iia-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 12px;
font-size: 14px;
}
.iia-summary {
flex: 1;
min-width: 0;
}
.iia-toggle {
padding: 2px 10px;
border: 1px solid #e0a43a;
border-radius: 12px;
background: transparent;
color: inherit;
font-size: 12px;
cursor: pointer;
}
.iia-item {
padding-left: 12px;
font-size: 13px;
color: inherit;
text-decoration: none;
}
.iia-more {
padding-left: 12px;
font-size: 12px;
font-style: italic;
}
a.iia-item:hover {
text-decoration: underline;
}
.iia-action {
align-self: center;
font-weight: 600;
white-space: nowrap;
color: inherit;
text-decoration: none;
}
}
+35
View File
@@ -26,6 +26,7 @@ import { PopoverService } from '../configuration-wizard/popover/popover.service'
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service'; import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { DscService } from 'app/users/dsc/dsc.service'; import { DscService } from 'app/users/dsc/dsc.service';
import { InvestmentFdDueDto, InvestmentInterestDueDto, InvestmentService } from 'app/investment/investment.service';
/** Custom Components */ /** Custom Components */
import { NextStepDialogComponent } from '../configuration-wizard/next-step-dialog/next-step-dialog.component'; import { NextStepDialogComponent } from '../configuration-wizard/next-step-dialog/next-step-dialog.component';
@@ -109,6 +110,7 @@ export class HomeComponent implements OnInit, AfterViewInit {
this.tenant = this.tenantIdentifier(); this.tenant = this.tenantIdentifier();
this.setFilteredActivities(); this.setFilteredActivities();
this.loadDscStatus(); this.loadDscStatus();
this.loadPendingInvestmentInterest();
if (!this.authenticationService.hasDialogBeenShown()) { if (!this.authenticationService.hasDialogBeenShown()) {
this.dialog.open(WarningDialogComponent, { this.dialog.open(WarningDialogComponent, {
disableClose: false, disableClose: false,
@@ -118,6 +120,39 @@ export class HomeComponent implements OnInit, AfterViewInit {
} }
} }
/** Investment interest lines due now or within two days, shown on the home screen so nobody has to look for them. */
pendingInvestmentInterest: InvestmentInterestDueDto[] = [];
/** FDs placed with other banks: interest vouchers and maturities due now or within two days. */
pendingFd: InvestmentFdDueDto[] = [];
/** The lists stay folded to one line each; the user opens them when needed. */
showInterestDetails = false;
showFdDetails = false;
readonly detailLimit = 5;
private readonly investmentService = inject(InvestmentService);
/** " · 3 overdue, oldest 14-08-2025" for a list of due entries (dates DD-MM-YYYY); empty when none is overdue. */
overdueNote(items: Array<{ overdue?: boolean; intDate: string }>): string {
const late = items.filter((t) => t.overdue);
if (!late.length) {
return '';
}
const key = (d: string) => d.split('-').reverse().join('');
const oldest = late.map((t) => t.intDate).sort((a, b) => key(a).localeCompare(key(b)))[0];
return ` · ${late.length} overdue, oldest ${oldest}`;
}
/** Silent when the user cannot see investments or nothing is due — the strip simply does not render. */
private loadPendingInvestmentInterest(): void {
this.investmentService.getDueInterest().subscribe({
next: (due) => (this.pendingInvestmentInterest = due ?? []),
error: () => (this.pendingInvestmentInterest = [])
});
this.investmentService.getFdDue().subscribe({
next: (due) => (this.pendingFd = due ?? []),
error: () => (this.pendingFd = [])
});
}
/** /**
* Surface a small "DSC verified" badge in the welcome banner when the * Surface a small "DSC verified" badge in the welcome banner when the
* signed-in user has at least one ACTIVE DSC certificate. Silent on * signed-in user has at least one ACTIVE DSC certificate. Silent on
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,198 @@
<!--
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/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
<h1 class="pg-title">Bucket Wise Investment</h1>
<p class="pg-sub">
Govt Securities, bonds and FDs held on a date, split by the time left to maturity. Click a bucket to see the
holdings in it.
</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="!report || generating" (click)="exportExcel()">
{{ generating ? 'Generating…' : 'Export to Excel' }}
</button>
</header>
<div class="pg-card">
<div class="inv-form-grid">
<label class="inv-form-field"
><span>As on</span><mifosx-date-ddmmyyyy [(ngModel)]="asOnDate"></mifosx-date-ddmmyyyy
></label>
</div>
<div class="diary-actions">
<button type="button" class="pg-btn pg-btn--primary" [disabled]="loading" (click)="load()">
{{ loading ? 'Loading…' : 'Show' }}
</button>
</div>
@if (error) {
<p class="diary-error">{{ error }}</p>
}
</div>
@if (report; as r) {
<div class="diary-summary">
<div class="diary-tile">
<span class="diary-tile-l">Total Investment</span
><span class="diary-tile-v">₹{{ r.total.totalAmount | number: '1.2-2' }}</span>
</div>
<div class="diary-tile">
<span class="diary-tile-l">Govt Securities (book value)</span
><span class="diary-tile-v">₹{{ r.total.govtBookValue | number: '1.2-2' }}</span>
</div>
@if (hasBonds) {
<div class="diary-tile">
<span class="diary-tile-l">Bonds / Non-SLR (book value)</span
><span class="diary-tile-v">₹{{ r.total.bondBookValue | number: '1.2-2' }}</span>
</div>
}
<div class="diary-tile">
<span class="diary-tile-l">FD with banks</span
><span class="diary-tile-v">₹{{ r.total.fdPrincipal | number: '1.2-2' }}</span>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">As on {{ r.asOnDate }} · by time left to maturity</div>
<table class="pg-table">
<thead>
<tr>
<th>Bucket</th>
<th class="num">Govt Sec</th>
<th class="num">Govt Sec Face Value</th>
<th class="num">Govt Sec Book Value</th>
@if (hasBonds) {
<th class="num">Bonds</th>
<th class="num">Bonds Book Value</th>
}
<th class="num">FD</th>
<th class="num">FD Principal</th>
<th class="num">Total Amount</th>
<th class="num">Share</th>
</tr>
</thead>
<tbody>
@for (b of r.buckets; track b.code) {
<tr class="report-pick" [class.report-pick--on]="bucket === b.code" (click)="pickBucket(b)">
<td>
<strong>{{ b.label }}</strong>
</td>
<td class="num">{{ b.govtCount }}</td>
<td class="num">₹{{ b.govtFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ b.govtBookValue | number: '1.2-2' }}</td>
@if (hasBonds) {
<td class="num">{{ b.bondCount }}</td>
<td class="num">₹{{ b.bondBookValue | number: '1.2-2' }}</td>
}
<td class="num">{{ b.fdCount }}</td>
<td class="num">₹{{ b.fdPrincipal | number: '1.2-2' }}</td>
<td class="num">₹{{ b.totalAmount | number: '1.2-2' }}</td>
<td class="num">{{ share(b) | number: '1.2-2' }}%</td>
</tr>
}
</tbody>
<tfoot>
<tr class="report-total">
<td>Total</td>
<td class="num">{{ r.total.govtCount }}</td>
<td class="num">₹{{ r.total.govtFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ r.total.govtBookValue | number: '1.2-2' }}</td>
@if (hasBonds) {
<td class="num">{{ r.total.bondCount }}</td>
<td class="num">₹{{ r.total.bondBookValue | number: '1.2-2' }}</td>
}
<td class="num">{{ r.total.fdCount }}</td>
<td class="num">₹{{ r.total.fdPrincipal | number: '1.2-2' }}</td>
<td class="num">₹{{ r.total.totalAmount | number: '1.2-2' }}</td>
<td class="num">{{ r.total.totalAmount > 0 ? '100.00%' : '—' }}</td>
</tr>
</tfoot>
</table>
</div>
<div class="pg-card">
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Bucket</span>
<select [(ngModel)]="bucket">
<option value="">All buckets</option>
@for (b of r.buckets; track b.code) {
<option [value]="b.code">{{ b.label }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Investment</span>
<select [(ngModel)]="instrument">
<option value="">All</option>
<option value="GOVT_SECURITY">Govt Security</option>
@if (hasBonds) {
<option value="BOND">Bond / Non-SLR</option>
}
<option value="FD">FD with banks</option>
</select>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Bucket</th>
<th>Investment</th>
<th>Deal No</th>
<th>Particulars</th>
<th>Category</th>
<th class="num">Rate</th>
<th>Maturity</th>
<th>Time Left</th>
<th class="num">Face Value / Principal</th>
<th class="num">Book Value / Principal</th>
</tr>
</thead>
<tbody>
@for (x of rows; track x.instrument + x.refId) {
<tr>
<td>{{ bucketLabel(x.bucket) }}</td>
<td>{{ instrumentLabel(x.instrument) }}</td>
<td>
<span class="pg-chip">{{ x.dealNo }}</span>
</td>
<td>{{ x.particulars }}</td>
<td>{{ x.category || '—' }}</td>
<td class="num">{{ x.rate !== null && x.rate !== undefined ? x.rate + '%' : '—' }}</td>
<td>{{ x.maturityDate }}</td>
<td>
@if (x.daysToMaturity < 0) {
<span class="inv-badge diary-badge--overdue">{{ timeLeft(x) }}</span>
} @else {
{{ timeLeft(x) }}
}
</td>
<td class="num">₹{{ x.faceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ x.bookValue | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="pg-empty">No investment held in this selection.</td>
</tr>
}
</tbody>
@if (rows.length) {
<tfoot>
<tr class="report-total">
<td colspan="8">Total ({{ rows.length }})</td>
<td class="num">₹{{ rowsTotal.faceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ rowsTotal.bookValue | number: '1.2-2' }}</td>
</tr>
</tfoot>
}
</table>
</div>
}
</div>
@@ -0,0 +1,235 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { todayDDMMYYYY } from '../investment-data';
import { exportWorkbook } from '../investment-export';
import {
InvestmentBucketDto,
InvestmentBucketReportDto,
InvestmentBucketRowDto,
InvestmentService,
apiErrorMessage
} from '../investment.service';
type InstrumentFilter = '' | InvestmentBucketRowDto['instrument'];
const INSTRUMENT_LABEL: Record<InvestmentBucketRowDto['instrument'], string> = {
GOVT_SECURITY: 'Govt Security',
BOND: 'Bond / Non-SLR',
FD: 'FD'
};
/**
* Bucket Wise Investment: Govt Securities, bonds and FDs held on a date, split by the time left to maturity — up to
* 1 year, over 1 to 3, over 3 to 5 and over 5 years. Clicking a bucket lists the holdings in it.
*/
@Component({
selector: 'mifosx-investment-bucket-report',
templateUrl: './investment-bucket-report.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss',
'../investment-diary/investment-diary.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class InvestmentBucketReportComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
asOnDate = todayDDMMYYYY();
bucket = '';
instrument: InstrumentFilter = '';
report: InvestmentBucketReportDto | null = null;
loading = false;
generating = false;
error = '';
ngOnInit(): void {
this.load();
}
load(): void {
if (!this.asOnDate) {
this.error = 'Enter the As on date.';
return;
}
this.loading = true;
this.error = '';
this.investmentService.getBucketReport(this.asOnDate).subscribe({
next: (r: InvestmentBucketReportDto) => {
this.report = r;
this.loading = false;
},
error: (err: unknown) => {
this.report = null;
this.loading = false;
this.error = apiErrorMessage(err);
}
});
}
/** Bonds / Non-SLR columns only when any are held. */
get hasBonds(): boolean {
return (this.report?.total.bondCount ?? 0) > 0;
}
get rows(): InvestmentBucketRowDto[] {
const all: InvestmentBucketRowDto[] = this.report?.rows ?? [];
return all.filter(
(r: InvestmentBucketRowDto) =>
(!this.bucket || r.bucket === this.bucket) && (!this.instrument || r.instrument === this.instrument)
);
}
get rowsTotal(): { faceValue: number; bookValue: number } {
return this.rows.reduce(
(t: { faceValue: number; bookValue: number }, r: InvestmentBucketRowDto) => ({
faceValue: t.faceValue + r.faceValue,
bookValue: t.bookValue + r.bookValue
}),
{ faceValue: 0, bookValue: 0 }
);
}
pickBucket(b: InvestmentBucketDto): void {
this.bucket = this.bucket === b.code ? '' : b.code;
}
bucketLabel(code: string): string {
return this.report?.buckets.find((b: InvestmentBucketDto) => b.code === code)?.label ?? code;
}
instrumentLabel(i: InvestmentBucketRowDto['instrument']): string {
return INSTRUMENT_LABEL[i];
}
/** Share of the bucket in the total amount, in %. */
share(b: InvestmentBucketDto): number {
const total = this.report?.total.totalAmount ?? 0;
return total > 0 ? (b.totalAmount / total) * 100 : 0;
}
/** Time left to maturity as years and months, or how long it is overdue. */
timeLeft(r: InvestmentBucketRowDto): string {
if (r.daysToMaturity < 0) {
return `Overdue ${-r.daysToMaturity} days`;
}
const months = Math.floor(r.daysToMaturity / 30.4375);
const years = Math.floor(months / 12);
if (years === 0 && months === 0) {
return `${r.daysToMaturity} days`;
}
return years > 0 ? `${years}y ${months % 12}m` : `${months}m`;
}
async exportExcel(): Promise<void> {
const report = this.report;
if (!report) {
return;
}
this.generating = true;
try {
const bucketRow = (b: InvestmentBucketDto): (string | number | null)[] => [
b.label,
b.govtCount,
b.govtFaceValue,
b.govtBookValue,
...(this.hasBonds ? [
b.bondCount,
b.bondFaceValue,
b.bondBookValue
] : []),
b.fdCount,
b.fdPrincipal,
b.totalAmount,
Number(this.share(b).toFixed(2))
];
await exportWorkbook(
[
{
sheetName: 'Bucket Summary',
title: 'Bucket Wise Investment',
subtitle: `As on ${report.asOnDate} · by time left to maturity`,
headers: [
'Bucket',
'Govt Sec (No.)',
'Govt Sec Face Value',
'Govt Sec Book Value',
...(this.hasBonds ? [
'Bonds (No.)',
'Bonds Face Value',
'Bonds Book Value'
] : []),
'FD (No.)',
'FD Principal',
'Total Amount',
'Share %'
],
rows: [
...report.buckets.map(bucketRow),
bucketRow(report.total)
]
},
{
sheetName: 'Holdings',
title: 'Bucket Wise Investment — Holdings',
subtitle: `As on ${report.asOnDate}`,
headers: [
'Bucket',
'Investment',
'Deal No',
'Particulars',
'Category',
'Rate %',
'Maturity',
'Time Left',
'Face Value / Principal',
'Book Value / Principal'
],
widths: [
22,
16,
16,
40,
10,
8,
12,
16,
20,
20
],
rows: report.rows.map((r: InvestmentBucketRowDto) => [
this.bucketLabel(r.bucket),
this.instrumentLabel(r.instrument),
r.dealNo,
r.particulars,
r.category ?? '',
r.rate ?? null,
r.maturityDate,
this.timeLeft(r),
r.faceValue,
r.bookValue
])
}
],
`Bucket-Wise-Investment-${report.asOnDate}`
);
} finally {
this.generating = false;
}
}
}
@@ -12,34 +12,50 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT</div> <div class="pg-eyebrow">TREASURY · INVESTMENT</div>
<h1 class="pg-title">Investment Portfolio Dashboard</h1> <h1 class="pg-title">Investment Portfolio Dashboard</h1>
<p class="pg-sub"> <p class="pg-sub">
Consolidated position across FD, Govt Security, Non-SLR, Bonds, Mutual Fund and Share holdings as on What the bank holds today across Govt Securities, FDs with banks, Non-SLR, Bonds, Mutual Funds and Shares, as on
{{ asOnDate }}. {{ asOnDate }}.
</p> </p>
</div> </div>
<div class="pg-toolbar"> <div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()"> <button type="button" class="pg-btn pg-btn--ghost" [disabled]="loading" (click)="load()">
{{ loading ? 'Loading…' : 'Refresh' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating || loading" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }} {{ generating ? 'Exporting…' : 'Export to Excel' }}
</button> </button>
</div> </div>
</header> </header>
@if (error) {
<div class="pg-card">
<p class="inv-hint inv-hint--bad">{{ error }}</p>
</div>
}
<div class="inv-kpis"> <div class="inv-kpis">
<div class="inv-kpi"> <div class="inv-kpi">
<span class="inv-kpi__label">Total Holdings</span> <span class="inv-kpi__label">Holdings</span>
<span class="inv-kpi__value">{{ totalHoldings }}</span> <span class="inv-kpi__value">{{ totalHoldings }}</span>
<span class="inv-kpi__sub">{{ activeCount }} active · {{ maturedCount }} matured</span> <span class="inv-kpi__sub"
>{{ maturingSoon.length }} due within 90 days{{ overdueCount ? ' (' + overdueCount + ' overdue)' : '' }}</span
>
</div> </div>
<div class="inv-kpi"> <div class="inv-kpi">
<span class="inv-kpi__label">Total Invested (Face Value)</span> <span class="inv-kpi__label">Total Invested (Book Value)</span>
<span class="inv-kpi__value">₹{{ totalFaceValue | number: '1.2-2' }}</span> <span class="inv-kpi__value">₹{{ totalBookValue | number: '1.2-2' }}</span>
<span class="inv-kpi__sub">Face value ₹{{ totalFaceValue | number: '1.2-2' }}</span>
</div> </div>
<div class="inv-kpi"> <div class="inv-kpi">
<span class="inv-kpi__label">Accrued Interest / Income</span> <span class="inv-kpi__label">Interest Receivable</span>
<span class="inv-kpi__value col-in">₹{{ totalAccruedInterest | number: '1.2-2' }}</span> <span class="inv-kpi__value col-in">₹{{ totalAccruedInterest | number: '1.2-2' }}</span>
<span class="inv-kpi__sub"
>Earned till today, not received · ₹{{ totalProvisioned | number: '1.2-2' }} provisioned</span
>
</div> </div>
<div class="inv-kpi"> <div class="inv-kpi">
<span class="inv-kpi__label">Projected Value at Maturity</span> <span class="inv-kpi__label">Value at Maturity</span>
<span class="inv-kpi__value">₹{{ totalMaturityValue | number: '1.2-2' }}</span> <span class="inv-kpi__value">₹{{ totalMaturityValue | number: '1.2-2' }}</span>
<span class="inv-kpi__sub">Securities at face value, FDs with interest</span>
</div> </div>
</div> </div>
@@ -51,7 +67,9 @@
<th>Instrument Type</th> <th>Instrument Type</th>
<th class="num">Holdings</th> <th class="num">Holdings</th>
<th class="num">Face Value</th> <th class="num">Face Value</th>
<th class="num col-in">Accrued Interest</th> <th class="num">Book Value</th>
<th class="num">Share</th>
<th class="num col-in">Interest Receivable</th>
<th class="num col-net">Maturity Value</th> <th class="num col-net">Maturity Value</th>
<th></th> <th></th>
</tr> </tr>
@@ -62,6 +80,8 @@
<td>{{ s.label }}</td> <td>{{ s.label }}</td>
<td class="num">{{ s.count }}</td> <td class="num">{{ s.count }}</td>
<td class="num">₹{{ s.faceValue | number: '1.2-2' }}</td> <td class="num">₹{{ s.faceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ s.bookValue | number: '1.2-2' }}</td>
<td class="num">{{ s.sharePct | number: '1.1-1' }}%</td>
<td class="num col-in">₹{{ s.accruedInterest | number: '1.2-2' }}</td> <td class="num col-in">₹{{ s.accruedInterest | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ s.maturityValue | number: '1.2-2' }}</td> <td class="num col-net">₹{{ s.maturityValue | number: '1.2-2' }}</td>
<td> <td>
@@ -75,6 +95,8 @@
<td>Total</td> <td>Total</td>
<td class="num">{{ totalHoldings }}</td> <td class="num">{{ totalHoldings }}</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td> <td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ totalBookValue | number: '1.2-2' }}</td>
<td class="num">{{ totalBookValue > 0 ? '100%' : '—' }}</td>
<td class="num">₹{{ totalAccruedInterest | number: '1.2-2' }}</td> <td class="num">₹{{ totalAccruedInterest | number: '1.2-2' }}</td>
<td class="num">₹{{ totalMaturityValue | number: '1.2-2' }}</td> <td class="num">₹{{ totalMaturityValue | number: '1.2-2' }}</td>
<td></td> <td></td>
@@ -84,93 +106,70 @@
</div> </div>
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div> <div class="pos-section-label">RBI Limits</div>
<p class="pg-sub"> @if (limitNote) {
Govt Security, Non-SLR, Mutual Fund and Share targets trace to RBI's SLR floor (18% of NDTL) and Non-SLR ceiling <p class="inv-hint inv-hint--bad">{{ limitNote }}</p>
(≤10% of deposits) for Urban Co-operative Banks; Fixed Deposit and Bond are board-approved policy choices within }
the room that framework leaves. @if (limits.length) {
</p>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Instrument Type</th> <th>Limit</th>
<th>Target Basis</th> <th>Basis</th>
<th class="num">Target %</th> <th class="num">Allowed</th>
<th class="num">Actual %</th> <th class="num">Used</th>
<th class="num">Actual Value</th> <th class="num">Used %</th>
<th class="num">Variance</th>
<th>Status</th> <th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (a of allocationVsTarget; track a.type) { @for (l of limits; track l.label) {
<tr> <tr>
<td>{{ a.label }}</td> <td>{{ l.label }}</td>
<td>{{ l.basis }}</td>
<td class="num">₹{{ l.limit | number: '1.2-2' }}</td>
<td class="num">₹{{ l.used | number: '1.2-2' }}</td>
<td class="num">{{ l.usedPct === null ? '—' : (l.usedPct | number: '1.1-1') + '%' }}</td>
<td> <td>
<span class="inv-badge" [class.inv-badge--matured]="a.isRbiLinked"> <span class="inv-badge" [class.inv-badge--active]="l.within" [class.inv-badge--written_off]="!l.within">
{{ a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy' }} {{ l.within ? 'Within' : 'Above limit' }}
</span>
</td>
<td class="num">{{ a.targetPct }}%</td>
<td class="num">{{ a.actualPct }}%</td>
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
<td
class="num"
[class.col-out]="a.statusLabel !== 'Within Target'"
[class.col-in]="a.statusLabel === 'Within Target'"
>
{{ a.variancePct > 0 ? '+' : '' }}{{ a.variancePct }}%
</td>
<td>
<span
class="inv-badge"
[class.inv-badge--active]="a.statusLabel === 'Within Target'"
[class.inv-badge--sold]="a.statusLabel === 'Over-Allocated'"
[class.inv-badge--written_off]="a.statusLabel === 'Under-Allocated'"
>
{{ a.statusLabel }}
</span> </span>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">100%</td>
<td class="num">100%</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table> </table>
}
</div> </div>
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div> <div class="pos-section-label">Maturing Within 90 Days</div>
@if (maturingSoon.length === 0) { @if (maturingSoon.length === 0) {
<div class="pg-empty">No holdings maturing in the next 90 days.</div> <div class="pg-empty">No holdings maturing in the next 90 days.</div>
} @else { } @else {
<div> <div>
@for (h of maturingSoon; track h.certNo) { @for (m of maturingSoon; track m.dealNo) {
<div class="inv-maturity-row"> <div class="inv-maturity-row">
<span> <span>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ m.dealNo }}</span>
{{ h.scriptName }} — {{ h.party }} ({{ instrumentLabel(h.instrumentType) }}) {{ m.particulars }} ({{ m.instrument }})
</span> </span>
<span> <span>
Matures {{ h.maturityDate }} · Matures {{ m.maturityDate }} · ₹{{ m.amount | number: '1.2-2' }} ·
<span <span
class="inv-maturity-days" class="inv-maturity-days"
[class.inv-maturity-days--soon]="daysLeft(h) <= 30" [class.inv-maturity-days--soon]="m.days <= 30"
[class.inv-maturity-days--ok]="daysLeft(h) > 30" [class.inv-maturity-days--ok]="m.days > 30"
> >
{{ daysLeft(h) }} days {{ m.days < 0 ? 'Overdue ' + -m.days + ' days' : m.days + ' days' }}
</span> </span>
</span> </span>
</div> </div>
} }
</div> </div>
<div class="inv-form-actions" style="padding: 8px 16px">
<a class="pg-btn pg-btn--ghost" routerLink="/investment/diary">Open Due Date Diary</a>
</div>
} }
</div> </div>
@@ -179,19 +178,17 @@
<div class="inv-nav-grid" style="padding: 16px"> <div class="inv-nav-grid" style="padding: 16px">
<a class="inv-nav-card" routerLink="/investment/fd"> <a class="inv-nav-card" routerLink="/investment/fd">
<span class="inv-nav-card__label">Fixed Deposit</span> <span class="inv-nav-card__label">Fixed Deposit</span>
<span class="inv-nav-card__count">{{ summaries[0].count }}</span> <span class="inv-nav-card__count">{{ countOf('FD') }}</span>
<span class="inv-nav-card__value">Purchase / Interest / Sell / Write-off</span> <span class="inv-nav-card__value">Purchase / Interest / Maturity / Renewal</span>
</a> </a>
<a class="inv-nav-card" routerLink="/investment/bonds"> <a class="inv-nav-card" routerLink="/investment/bonds">
<span class="inv-nav-card__label">Govt Security / Non-SLR / Bonds / MF</span> <span class="inv-nav-card__label">Govt Security / Non-SLR / Bonds / MF</span>
<span class="inv-nav-card__count">{{ <span class="inv-nav-card__count">{{ countOf('GOVT_SECURITY', 'NON_SLR', 'BOND', 'MUTUAL_FUND') }}</span>
summaries[1].count + summaries[2].count + summaries[3].count + summaries[4].count
}}</span>
<span class="inv-nav-card__value">Purchase / Interest / Sell / Amortize</span> <span class="inv-nav-card__value">Purchase / Interest / Sell / Amortize</span>
</a> </a>
<a class="inv-nav-card" routerLink="/investment/share"> <a class="inv-nav-card" routerLink="/investment/share">
<span class="inv-nav-card__label">Share Entry</span> <span class="inv-nav-card__label">Share Entry</span>
<span class="inv-nav-card__count">{{ summaries[5].count }}</span> <span class="inv-nav-card__count">{{ countOf('SHARE') }}</span>
<span class="inv-nav-card__value">Purchase / Dividend / Sell / Write-off</span> <span class="inv-nav-card__value">Purchase / Dividend / Sell / Write-off</span>
</a> </a>
<a class="inv-nav-card" routerLink="/investment/interest"> <a class="inv-nav-card" routerLink="/investment/interest">
@@ -6,21 +6,25 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { import { forkJoin } from 'rxjs';
ALL_HOLDINGS, import { INSTRUMENT_LABELS, InstrumentType, parseDDMMYYYY, todayDDMMYYYY } from '../investment-data';
ALLOCATION_TARGET_IS_RBI_LINKED,
INSTRUMENT_LABELS,
InstrumentType,
InvestmentHolding,
TARGET_ALLOCATION_PCT,
accruedInterest,
daysToMaturity,
maturityAmount
} from '../investment-data';
import { exportWorkbook } from '../investment-export'; import { exportWorkbook } from '../investment-export';
import {
InvestmentBucketReportDto,
InvestmentBucketRowDto,
InvestmentFdDto,
InvestmentFdLimitDto,
InvestmentInterestReportDto,
InvestmentNonSlrLimitDto,
InvestmentPurchaseDto,
InvestmentSaleDto,
InvestmentService,
apiErrorMessage,
bookValueSoldByPurchase
} from '../investment.service';
interface InstrumentSummary { interface InstrumentSummary {
type: InstrumentType; type: InstrumentType;
@@ -28,25 +32,45 @@ interface InstrumentSummary {
route: string; route: string;
count: number; count: number;
faceValue: number; faceValue: number;
bookValue: number;
accruedInterest: number; accruedInterest: number;
maturityValue: number; maturityValue: number;
sharePct: number;
} }
interface AllocationRow { interface LimitRow {
type: InstrumentType;
label: string; label: string;
targetPct: number; basis: string;
actualPct: number; limit: number | null;
actualValue: number; used: number;
variancePct: number; usedPct: number | null;
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated'; within: boolean;
/** 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 /** A holding maturing soon, or past maturity and not yet redeemed / closed. */
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */ interface MaturingRow {
dealNo: string;
particulars: string;
instrument: string;
maturityDate: string;
days: number;
amount: number;
}
const ROUTE: Record<InstrumentType, string> = {
FD: '/investment/fd',
GOVT_SECURITY: '/investment/bonds',
NON_SLR: '/investment/bonds',
BOND: '/investment/bonds',
MUTUAL_FUND: '/investment/bonds',
SHARE: '/investment/share'
};
/**
* Cross-instrument portfolio dashboard on live data: what is held today by instrument (face value, book value,
* interest earned and not yet received, value at maturity), where the portfolio stands against the RBI limits, and
* what matures in the next 90 days.
*/
@Component({ @Component({
selector: 'mifosx-investment-dashboard', selector: 'mifosx-investment-dashboard',
templateUrl: './investment-dashboard.component.html', templateUrl: './investment-dashboard.component.html',
@@ -60,107 +84,220 @@ interface AllocationRow {
RouterLink RouterLink
] ]
}) })
export class InvestmentDashboardComponent { export class InvestmentDashboardComponent implements OnInit {
asOnDate = ''; private readonly investmentService = inject(InvestmentService);
asOnDate = todayDDMMYYYY();
loading = false;
error = '';
generating = false; generating = false;
toastMsg = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
private readonly types: Array<{ type: InstrumentType; route: string }> = [
{ type: 'FD', route: '/investment/fd' }, summaries: InstrumentSummary[] = [];
{ type: 'GOVT_SECURITY', route: '/investment/bonds' }, limits: LimitRow[] = [];
{ type: 'NON_SLR', route: '/investment/bonds' }, limitNote = '';
{ type: 'BOND', route: '/investment/bonds' }, maturingSoon: MaturingRow[] = [];
{ type: 'MUTUAL_FUND', route: '/investment/bonds' }, totalReceivable = 0;
{ type: 'SHARE', route: '/investment/share' } totalProvisioned = 0;
ngOnInit(): void {
this.load();
}
load(): void {
const today = this.asOnDate;
const d = parseDDMMYYYY(today);
const fyStart = `01-04-${d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1}`;
this.loading = true;
this.error = '';
forkJoin({
bucket: this.investmentService.getBucketReport(today),
interest: this.investmentService.getInterestReport(fyStart, today),
purchases: this.investmentService.getPurchases(),
sales: this.investmentService.getSales(),
fds: this.investmentService.getFds(),
fdQuote: this.investmentService.quoteFd({ amount: 0, intRate: 0, dealDate: today }),
nonSlr: this.investmentService.getNonSlrLimit(null, today, 0)
}).subscribe({
next: (r) => {
this.build(r.bucket, r.interest, r.purchases, r.sales, r.fds, r.fdQuote.limit ?? null, r.nonSlr);
this.loading = false;
},
error: (err: unknown) => {
this.loading = false;
this.error = apiErrorMessage(err);
}
});
}
private build(
bucket: InvestmentBucketReportDto,
interest: InvestmentInterestReportDto,
purchases: InvestmentPurchaseDto[],
sales: InvestmentSaleDto[],
fds: InvestmentFdDto[],
fdLimit: InvestmentFdLimitDto | null,
nonSlr: InvestmentNonSlrLimitDto
): void {
const purchaseById = new Map<number, InvestmentPurchaseDto>(
purchases.map((p) => [
p.id,
p
])
);
const fdById = new Map<number, InvestmentFdDto>(
fds
.filter((f) => f.id != null)
.map((f) => [
f.id as number,
f
])
);
const receivable = new Map<string, number>(
interest.rows.map((x) => [
x.instrument + ':' + x.refId,
x.receivable
])
);
this.totalReceivable = interest.totalReceivable;
this.totalProvisioned = interest.totalProvisioned;
// Bucket rows are the Govt Securities, bonds and FDs held today; the purchase tells a Non-SLR from a Bond.
const typeOf = (r: InvestmentBucketRowDto): InstrumentType =>
r.instrument === 'FD' ? 'FD' : (purchaseById.get(r.refId)?.bondType ?? 'BOND');
const acc = new Map<InstrumentType, InstrumentSummary>();
const sum = (type: InstrumentType): InstrumentSummary => {
let s = acc.get(type);
if (!s) {
s = {
type,
label: INSTRUMENT_LABELS[type],
route: ROUTE[type],
count: 0,
faceValue: 0,
bookValue: 0,
accruedInterest: 0,
maturityValue: 0,
sharePct: 0
};
acc.set(type, s);
}
return s;
};
for (const r of bucket.rows) {
const s = sum(typeOf(r));
s.count += 1;
s.faceValue += r.faceValue;
s.bookValue += r.bookValue;
s.accruedInterest += receivable.get((r.instrument === 'FD' ? 'FD' : 'SECURITY') + ':' + r.refId) ?? 0;
// A security is redeemed at face value; an FD pays its maturity amount (just the principal on a payout FD).
s.maturityValue +=
r.instrument === 'FD' ? (fdById.get(r.refId)?.expectedMaturityAmount ?? r.faceValue) : r.faceValue;
}
// Mutual funds and shares have no maturity, so they are not in the buckets; the units still held are at cost.
const sold = bookValueSoldByPurchase(sales);
for (const p of purchases) {
if ((p.bondType === 'MUTUAL_FUND' || p.bondType === 'SHARE') && p.status === 'ACTIVE') {
const s = sum(p.bondType);
const book = p.bookValue - (sold.get(p.id) ?? 0);
s.count += 1;
s.faceValue += p.units ? (p.faceValue * p.balanceUnits) / p.units : p.faceValue;
s.bookValue += book;
s.maturityValue += book;
}
}
const order: InstrumentType[] = [
'GOVT_SECURITY',
'FD',
'NON_SLR',
'BOND',
'MUTUAL_FUND',
'SHARE'
]; ];
const total = [...acc.values()].reduce((t, s) => t + s.bookValue, 0);
this.summaries = order
.map((t) => sum(t))
.map((s) => ({ ...s, sharePct: total > 0 ? (s.bookValue / total) * 100 : 0 }));
constructor() { this.limits = [];
const d = new Date(); this.limitNote = '';
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; if (fdLimit?.grossLimit != null) {
const used = fdLimit.fdOutstanding + fdLimit.bankBalances;
this.limits.push(
this.limitRow(
'Deposits with other banks',
'FDs + current accounts, ≤ 20% of deposits',
fdLimit.grossLimit,
used
)
);
}
if (nonSlr.nonSlrLimit != null) {
this.limits.push(
this.limitRow(
'Non-SLR investments',
'Bonds, CP / CD, MF, ≤ 10% of deposits',
nonSlr.nonSlrLimit,
nonSlr.nonSlrOutstanding
)
);
this.limits.push(
this.limitRow(
'Unlisted non-SLR',
`≤ 10% of non-SLR held on ${nonSlr.asOnDate}`,
nonSlr.unlistedLimit,
nonSlr.unlistedOutstanding
)
);
} else {
this.limitNote = `Total deposits as on ${nonSlr.asOnDate} are not entered yet; enter them on the FD screen to see the RBI limits.`;
} }
private holdingsOf(type: InstrumentType): InvestmentHolding[] { this.maturingSoon = bucket.rows
return ALL_HOLDINGS.filter((h) => h.instrumentType === type); .filter((r) => r.daysToMaturity <= 90)
.map((r) => ({
dealNo: r.dealNo,
particulars: r.particulars,
instrument: INSTRUMENT_LABELS[typeOf(r)],
maturityDate: r.maturityDate,
days: r.daysToMaturity,
amount: r.instrument === 'FD' ? (fdById.get(r.refId)?.expectedMaturityAmount ?? r.faceValue) : r.faceValue
}));
} }
get summaries(): InstrumentSummary[] { private limitRow(label: string, basis: string, limit: number, used: number): LimitRow {
return this.types.map(({ type, route }) => { return { label, basis, limit, used, usedPct: limit > 0 ? (used / limit) * 100 : null, within: used <= limit };
const holdings = this.holdingsOf(type);
return {
type,
label: INSTRUMENT_LABELS[type],
route,
count: holdings.length,
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
accruedInterest: holdings.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0),
maturityValue: holdings.reduce((s, h) => s + maturityAmount(h), 0)
};
});
}
/** 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,
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
};
});
} }
get totalHoldings(): number { get totalHoldings(): number {
return ALL_HOLDINGS.length; return this.summaries.reduce((t, s) => t + s.count, 0);
} }
get totalFaceValue(): number { get totalFaceValue(): number {
return ALL_HOLDINGS.reduce((s, h) => s + h.faceValue, 0); return this.summaries.reduce((t, s) => t + s.faceValue, 0);
}
get totalBookValue(): number {
return this.summaries.reduce((t, s) => t + s.bookValue, 0);
} }
get totalAccruedInterest(): number { get totalAccruedInterest(): number {
return ALL_HOLDINGS.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0); return this.summaries.reduce((t, s) => t + s.accruedInterest, 0);
} }
get totalMaturityValue(): number { get totalMaturityValue(): number {
return ALL_HOLDINGS.reduce((s, h) => s + maturityAmount(h), 0); return this.summaries.reduce((t, s) => t + s.maturityValue, 0);
} }
get activeCount(): number { get overdueCount(): number {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE').length; return this.maturingSoon.filter((m) => m.days < 0).length;
} }
get maturedCount(): number { /** Holdings of one or more instrument types, for the Quick Entry cards. */
return ALL_HOLDINGS.filter((h) => h.status === 'MATURED').length; countOf(...types: InstrumentType[]): number {
} return this.summaries.filter((s) => types.includes(s.type)).reduce((t, s) => t + s.count, 0);
/** Holdings maturing within the next 90 days — a "due-date diary" style widget. */
get maturingSoon(): InvestmentHolding[] {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && daysToMaturity(h, this.asOnDate) <= 90).sort(
(a, b) => daysToMaturity(a, this.asOnDate) - daysToMaturity(b, this.asOnDate)
);
}
daysLeft(h: InvestmentHolding): number {
return daysToMaturity(h, this.asOnDate);
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
} }
async exportExcel(): Promise<void> { async exportExcel(): Promise<void> {
@@ -176,54 +313,97 @@ export class InvestmentDashboardComponent {
'Instrument Type', 'Instrument Type',
'Holdings', 'Holdings',
'Face Value', 'Face Value',
'Accrued Interest', 'Book Value',
'Share %',
'Interest Receivable',
'Maturity Value' 'Maturity Value'
], ],
widths: [ widths: [
26, 22,
12, 10,
18, 18,
18, 18,
10,
18,
18 18
], ],
rows: this.summaries.map((s) => [ rows: [
...this.summaries.map((s) => [
s.label, s.label,
s.count, s.count,
s.faceValue, s.faceValue,
s.bookValue,
Number(s.sharePct.toFixed(2)),
s.accruedInterest, s.accruedInterest,
s.maturityValue s.maturityValue
]) ]),
[
'Total',
this.totalHoldings,
this.totalFaceValue,
this.totalBookValue,
100,
this.totalAccruedInterest,
this.totalMaturityValue
]
]
}, },
{ {
sheetName: 'Portfolio Allocation', sheetName: 'RBI Limits',
title: 'Portfolio Allocation — Target vs Actual', title: 'RBI Limits',
subtitle: `As on ${this.asOnDate}`, subtitle: `As on ${this.asOnDate}`,
headers: [ headers: [
'Instrument Type', 'Limit',
'Target Basis', 'Basis',
'Target %', 'Allowed',
'Actual %', 'Used',
'Actual Value', 'Used %',
'Variance %',
'Status' 'Status'
], ],
widths: [ widths: [
26, 26,
20, 40,
12,
12,
18, 18,
18,
10,
14
],
rows: this.limits.map((l) => [
l.label,
l.basis,
l.limit,
l.used,
l.usedPct === null ? null : Number(l.usedPct.toFixed(2)),
l.within ? 'Within' : 'Above limit'
])
},
{
sheetName: 'Maturing in 90 days',
title: 'Maturing Within 90 Days (and overdue)',
subtitle: `As on ${this.asOnDate}`,
headers: [
'Deal No',
'Investment',
'Particulars',
'Maturity',
'Days',
'Amount'
],
widths: [
16,
16,
40,
12, 12,
10,
18 18
], ],
rows: this.allocationVsTarget.map((a) => [ rows: this.maturingSoon.map((m) => [
a.label, m.dealNo,
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy', m.instrument,
a.targetPct, m.particulars,
a.actualPct, m.maturityDate,
a.actualValue, m.days,
a.variancePct, m.amount
a.statusLabel
]) ])
} }
], ],
+580 -30
View File
@@ -17,7 +17,7 @@
*/ */
export type InstrumentType = 'FD' | 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND' | 'SHARE'; export type InstrumentType = 'FD' | 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND' | 'SHARE';
export type HoldingStatus = 'ACTIVE' | 'MATURED' | 'SOLD' | 'WRITTEN_OFF'; export type HoldingStatus = 'PENDING_SETTLEMENT' | 'ACTIVE' | 'MATURED' | 'SOLD' | 'WRITTEN_OFF';
export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' | 'CUMULATIVE'; export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' | 'CUMULATIVE';
export type TxnType = export type TxnType =
| 'PURCHASE' | 'PURCHASE'
@@ -28,7 +28,11 @@ export type TxnType =
| 'REDEEM' | 'REDEEM'
| 'WRITE_OFF' | 'WRITE_OFF'
| 'AMORTIZE' | 'AMORTIZE'
| 'PART_PAYMENT'; | 'TRANSFER'
| 'SWITCH'
| 'PART_PAYMENT'
| 'PREMATURE'
| 'RENEWAL';
export const INSTRUMENT_LABELS: Record<InstrumentType, string> = { export const INSTRUMENT_LABELS: Record<InstrumentType, string> = {
FD: 'Fixed Deposit', FD: 'Fixed Deposit',
@@ -123,6 +127,25 @@ export interface InvestmentHolding {
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping * 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. */ * or repeated period always yields 0 instead of double-posting the same interest. */
interestPostedTillDate?: string; interestPostedTillDate?: string;
/** RBI investment category chosen at purchase — HTM / AFS / HFT. */
category?: string;
/** SGL / CSGL account the security is held in (custody, not an accounting GL). */
sglAccountNo?: string;
/** Backend id / deal number / party code once the purchase is saved (absent on sample rows). */
id?: number;
dealNo?: string;
partyCode?: string;
bookValue?: number;
settlementAmount?: number;
/** How and when a closed FD ended (MATURITY / PREMATURE / RENEWAL). */
closureType?: string;
closedOnDate?: string;
/** Settlement date of the deal (deal date + T+n) — it can be settled on or after this day. */
settlementDate?: string;
/** Last coupon date before the purchase; coupons fall every six months from it. */
lastIntDate?: string;
/** Journal entry transaction posted when the deal was settled. */
glTransactionId?: string;
} }
export interface InvestmentTxn { export interface InvestmentTxn {
@@ -248,6 +271,80 @@ export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, to
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25)); return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
} }
/** Days between two DD-MM-YYYY dates on a 30/360 basis (every month 30 days), as the bank's existing
* CBS counts them — e.g. 10-08 → 30-09 = 50 days, 10-08 → 07-10 = 57 days. */
export function days360(fromStr: string, toStr: string): number {
const a = parseDDMMYYYY(fromStr);
const b = parseDDMMYYYY(toStr);
if (isNaN(a.getTime()) || isNaN(b.getTime()) || b <= a) {
return 0;
}
const d1 = Math.min(a.getDate(), 30);
const d2 = Math.min(b.getDate(), 30);
return (b.getFullYear() - a.getFullYear()) * 360 + (b.getMonth() - a.getMonth()) * 30 + (d2 - d1);
}
/** Most recent half-yearly coupon date on or before `onStr`, derived from the maturity date (coupons
* fall on the maturity day/month and six months either side of it). */
export function lastHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
const mat = parseDDMMYYYY(maturityStr);
const on = parseDDMMYYYY(onStr);
if (isNaN(mat.getTime()) || isNaN(on.getTime())) {
return '';
}
const day = mat.getDate();
const couponOn = (year: number, month: number): Date => {
const lastDay = new Date(year, month + 1, 0).getDate();
return new Date(year, month, Math.min(day, lastDay));
};
const months = [
mat.getMonth() % 6,
(mat.getMonth() % 6) + 6
];
let best: Date | undefined;
for (const year of [
on.getFullYear() - 1,
on.getFullYear()
]) {
for (const m of months) {
const c = couponOn(year, m);
if (c <= on && (!best || c > best)) {
best = c;
}
}
}
return best ? formatDDMMYYYY(best) : '';
}
/** First half-yearly coupon date after `onStr` (coupons fall on the maturity day/month and six months
* either side), never later than maturity. */
export function nextHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
const last = lastHalfYearlyCouponDate(maturityStr, onStr);
const mat = parseDDMMYYYY(maturityStr);
if (!last || isNaN(mat.getTime())) {
return '';
}
const l = parseDDMMYYYY(last);
const year = l.getFullYear() + (l.getMonth() >= 6 ? 1 : 0);
const month = (l.getMonth() + 6) % 12;
const next = new Date(year, month, Math.min(mat.getDate(), new Date(year, month + 1, 0).getDate()));
return formatDDMMYYYY(next > mat ? mat : next);
}
/** The two coupon days of a half-yearly security, e.g. "14 Feb / 14 Aug". */
export function couponDatesLabel(maturityStr: string): string {
const mat = parseDDMMYYYY(maturityStr);
if (isNaN(mat.getTime())) {
return '';
}
const label = (month: number): string => {
const day = Math.min(mat.getDate(), new Date(2001, month + 1, 0).getDate());
return `${day} ${new Date(2001, month, 1).toLocaleString('en-US', { month: 'short' })}`;
};
const first = mat.getMonth() % 6;
return `${label(first)} / ${label(first + 6)}`;
}
export function formatDDMMYYYY(d: Date): string { export function formatDDMMYYYY(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
} }
@@ -1404,99 +1501,405 @@ export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' } { glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
]; ];
/** Investment GLs a script / party can be mapped to. PROPOSED codes awaiting approval — the
* backend will take these from the chart of accounts, never hard-coded. */
export const INVESTMENT_GL_OPTIONS: InvestmentGlOption[] = [
{ glCode: '100701', glName: 'INVESTMENT IN CENTRAL GOVT SECURITIES' },
{ glCode: '100702', glName: 'INVESTMENT IN STATE GOVT SECURITIES' },
{ glCode: '100703', glName: 'INVESTMENT IN BONDS / DEBENTURES' },
{ glCode: '100704', glName: 'FIXED DEPOSITS WITH OTHER BANKS' },
{ glCode: '100705', glName: 'INVESTMENT IN MUTUAL FUNDS' },
{ glCode: '100706', glName: 'INVESTMENT IN SHARES' }
];
/** Fallback investment GL when a script has none mapped. */
export const INVESTMENT_GL_BY_TYPE: Record<InstrumentType, string> = {
GOVT_SECURITY: '100701',
NON_SLR: '100703',
BOND: '100703',
FD: '100704',
MUTUAL_FUND: '100705',
SHARE: '100706'
};
/** Broken-period interest paid to the seller at purchase — an expense, not part of cost (RBI UCB Investment
* Directions para 152). */
export const BROKEN_PERIOD_INTEREST_GL: InvestmentGlOption = {
glCode: '500606',
glName: 'BROKEN PERIOD INTEREST PAID ON INVESTMENTS'
};
/** Charges on an investment deal — expenses on their own GLs, not part of the book value (Jitendra Sir). */
export const STAMP_DUTY_GL: InvestmentGlOption = { glCode: '500601', glName: 'STAMP DUTY ON INVESTMENTS' };
export const BROKERAGE_GL: InvestmentGlOption = { glCode: '500602', glName: 'BROKERAGE ON INVESTMENTS' };
export const OTHER_CHARGES_GL: InvestmentGlOption = { glCode: '500603', glName: 'OTHER CHARGES ON INVESTMENTS' };
/** Premium paid over face value, held until amortised to maturity (Jitendra Sir). */
export const PREMIUM_ON_INVESTMENTS_GL: InvestmentGlOption = { glCode: '100707', glName: 'PREMIUM ON INVESTMENTS' };
/** Interest receivable (the senior's "Provision A/c") — debited for interest bought from the seller. */
export const INTEREST_RECEIVABLE_GL: InvestmentGlOption = {
glCode: '100603',
glName: 'INTEREST RECEIVABLE ON INVESTMENTS (PROVISION)'
};
/** Bank accounts a purchase can be paid from — these GLs already exist in the chart of accounts. */
export const SETTLEMENT_BANK_GL_OPTIONS: InvestmentGlOption[] = [
{ glCode: '100201', glName: 'IDBI BANK' },
{ glCode: '100202', glName: 'YES BANK' }
];
/** Every GL the Investment screens can show by name. */
export const INVESTMENT_GL_LOOKUP: InvestmentGlOption[] = [
...INVESTMENT_GL_OPTIONS,
INTEREST_RECEIVABLE_GL,
...SETTLEMENT_BANK_GL_OPTIONS
];
/** SLR classification on the Script Master, as in the bank's existing CBS ("GOI" = Central Govt). */
export type SlrType = 'GOI' | 'STATE_GOVT' | 'TREASURY' | 'NON_SLR';
export const SLR_TYPE_OPTIONS: Array<{ value: SlrType; label: string }> = [
{ value: 'GOI', label: 'GOI' },
{ value: 'STATE_GOVT', label: 'STATE GOVT' },
{ value: 'TREASURY', label: 'TREASURY' },
{ value: 'NON_SLR', label: 'NON SLR' }
];
export interface ScriptMaster { export interface ScriptMaster {
/** Backend id once the script is saved (absent on the sample rows). */
id?: number;
scriptCode: string; scriptCode: string;
scriptName: string; scriptName: string;
instrumentType: InstrumentType; instrumentType: InstrumentType;
isin: string; isin: string;
faceValuePerUnit: number; faceValuePerUnit: number;
// Fields below follow the bank's existing CBS Script Master; a purchase picks them up instead
// of the dealer re-typing them on every deal.
slrType?: SlrType;
/** Investment GL this script is booked to. */
glAccountId?: number;
glCode?: string;
glName?: string;
active?: boolean;
/** Interest (coupon) rate % per annum. */
couponRate?: number;
issueDate?: string;
maturityDate?: string;
// Issuer details — used for Bond / Mutual Fund scripts.
companyName?: string;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
corpCode?: string;
/** Credit rating — long-term for bonds, short-term for CP / CD (Non-SLR and Bond scripts). */
rating?: string;
ratingAgency?: string;
/** Listed on an exchange or proposed to be listed; unlisted ones have their own RBI limit. */
listed?: boolean;
}
/** Credit ratings on the Script Master, best first: long-term for bonds, short-term for CP / CD. */
export const RATING_OPTIONS: Array<{ group: string; values: string[] }> = [
{
group: 'Long-term (bonds)',
values: [
'AAA',
'AA+',
'AA',
'AA-',
'A+',
'A',
'A-',
'BBB+',
'BBB',
'BBB-',
'BB+',
'BB',
'BB-',
'B+',
'B',
'B-',
'C',
'D'
]
},
{
group: 'Short-term (CP / CD)',
values: [
'A1+',
'A1',
'A2+',
'A2',
'A3+',
'A3',
'A4+',
'A4'
]
},
{ group: 'None', values: ['UNRATED'] }
];
/** RBI: bonds rated "A" or higher, CP / CD rated "A2" or higher. */
export const ELIGIBLE_RATINGS: string[] = [
'AAA',
'AA+',
'AA',
'AA-',
'A+',
'A',
'A-',
'A1+',
'A1',
'A2+',
'A2'
];
/** Credit rating agencies registered with SEBI. */
export const RATING_AGENCY_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'CRISIL', label: 'CRISIL' },
{ value: 'ICRA', label: 'ICRA' },
{ value: 'CARE', label: 'CARE' },
{ value: 'INDIA_RATINGS', label: 'India Ratings' },
{ value: 'ACUITE', label: 'Acuite' },
{ value: 'BRICKWORK', label: 'Brickwork' },
{ value: 'INFOMERICS', label: 'Infomerics' }
];
/** Period between two DD-MM-YYYY dates as years / months / days, as shown on the Script Master. */
export function periodBetween(fromStr?: string, toStr?: string): { years: number; months: number; days: number } {
const none = { years: 0, months: 0, days: 0 };
if (!fromStr || !toStr) {
return none;
}
const from = parseDDMMYYYY(fromStr);
const to = parseDDMMYYYY(toStr);
if (isNaN(from.getTime()) || isNaN(to.getTime()) || to < from) {
return none;
}
let years = to.getFullYear() - from.getFullYear();
let months = to.getMonth() - from.getMonth();
let days = to.getDate() - from.getDate();
if (days < 0) {
months -= 1;
days += new Date(to.getFullYear(), to.getMonth(), 0).getDate();
}
if (months < 0) {
years -= 1;
months += 12;
}
return { years, months, days };
} }
export const SCRIPT_MASTER: ScriptMaster[] = [ export const SCRIPT_MASTER: ScriptMaster[] = [
{ scriptCode: 'FD-CUM', scriptName: 'Cumulative Fixed Deposit', instrumentType: 'FD', isin: '', faceValuePerUnit: 1 }, {
scriptCode: 'FD-CUM',
scriptName: 'Cumulative Fixed Deposit',
instrumentType: 'FD',
isin: '',
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100704'
},
{ {
scriptCode: 'FD-MIP', scriptCode: 'FD-MIP',
scriptName: 'Monthly Interest Payout FD', scriptName: 'Monthly Interest Payout FD',
instrumentType: 'FD', instrumentType: 'FD',
isin: '', isin: '',
faceValuePerUnit: 1 faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100704'
}, },
{ {
scriptCode: 'IN0020230012', scriptCode: 'IN0020230012',
scriptName: '7.26% GOI 2033', scriptName: '7.26% GOI 2033',
instrumentType: 'GOVT_SECURITY', instrumentType: 'GOVT_SECURITY',
isin: 'IN0020230012', isin: 'IN0020230012',
faceValuePerUnit: 100 faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.26,
issueDate: '15-01-2023',
maturityDate: '15-01-2033'
}, },
{ {
scriptCode: 'IN0020220089', scriptCode: 'IN0020220089',
scriptName: '7.10% GOI 2029', scriptName: '7.10% GOI 2029',
instrumentType: 'GOVT_SECURITY', instrumentType: 'GOVT_SECURITY',
isin: 'IN0020220089', isin: 'IN0020220089',
faceValuePerUnit: 100 faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.1,
issueDate: '10-03-2022',
maturityDate: '10-03-2029'
},
{
scriptCode: 'IN0020210056',
scriptName: '6.79% GOI 2027',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020210056',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 6.79,
issueDate: '05-06-2021',
maturityDate: '05-06-2027'
},
{
scriptCode: 'IN0020240034',
scriptName: '7.18% GOI 2037',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020240034',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.18,
issueDate: '22-04-2024',
maturityDate: '22-04-2037'
},
{
scriptCode: 'MH-SDL-2031',
scriptName: '8.05% Maharashtra SDL 2031',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'STATE_GOVT',
glCode: '100702',
couponRate: 8.05,
issueDate: '15-02-2021',
maturityDate: '15-02-2031'
},
{
scriptCode: 'GJ-SDL-2026',
scriptName: '7.85% Gujarat SDL 2026',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'STATE_GOVT',
glCode: '100702',
couponRate: 7.85,
issueDate: '18-09-2016',
maturityDate: '18-09-2026'
}, },
{ {
scriptCode: 'TBILL-364D', scriptCode: 'TBILL-364D',
scriptName: '364 Days Treasury Bill', scriptName: '364 Days Treasury Bill',
instrumentType: 'GOVT_SECURITY', instrumentType: 'GOVT_SECURITY',
isin: '', isin: '',
faceValuePerUnit: 100 faceValuePerUnit: 100,
slrType: 'TREASURY',
glCode: '100701',
couponRate: 0,
issueDate: '02-07-2026',
maturityDate: '02-07-2027'
},
{
scriptCode: 'TBILL-91D',
scriptName: '91 Days Treasury Bill',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'TREASURY',
glCode: '100701',
couponRate: 0,
issueDate: '15-05-2026',
maturityDate: '13-08-2026'
}, },
{ {
scriptCode: 'REC-7.5-2031', scriptCode: 'REC-7.5-2031',
scriptName: 'REC Ltd Bond 7.50% 2031', scriptName: 'REC Ltd Bond 7.50% 2031',
instrumentType: 'NON_SLR', instrumentType: 'NON_SLR',
isin: 'INE020B08JN0', isin: 'INE020B08JN0',
faceValuePerUnit: 100 faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.5,
issueDate: '11-08-2021',
maturityDate: '11-08-2031',
companyName: 'REC Limited',
city: 'New Delhi'
}, },
{ {
scriptCode: 'NABARD-7.35-2030', scriptCode: 'NABARD-7.35-2030',
scriptName: 'NABARD Bond 7.35% 2030', scriptName: 'NABARD Bond 7.35% 2030',
instrumentType: 'NON_SLR', instrumentType: 'NON_SLR',
isin: 'INE261F09EU1', isin: 'INE261F09EU1',
faceValuePerUnit: 100 faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.35,
issueDate: '20-09-2020',
maturityDate: '20-09-2030',
companyName: 'NABARD',
city: 'Mumbai'
}, },
{ {
scriptCode: 'LTF-8.1-2028', scriptCode: 'LTF-8.1-2028',
scriptName: 'L&T Finance NCD 8.10% 2028', scriptName: 'L&T Finance NCD 8.10% 2028',
instrumentType: 'BOND', instrumentType: 'BOND',
isin: 'INE027E07BR5', isin: 'INE027E07BR5',
faceValuePerUnit: 100 faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 8.1,
companyName: 'L&T Finance Ltd',
city: 'Mumbai'
}, },
{ {
scriptCode: 'BAF-7.95-2027', scriptCode: 'BAF-7.95-2027',
scriptName: 'Bajaj Finance NCD 7.95% 2027', scriptName: 'Bajaj Finance NCD 7.95% 2027',
instrumentType: 'BOND', instrumentType: 'BOND',
isin: 'INE296A07SP0', isin: 'INE296A07SP0',
faceValuePerUnit: 100 faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.95,
companyName: 'Bajaj Finance Ltd',
city: 'Pune'
}, },
{ {
scriptCode: 'SBI-OVN', scriptCode: 'SBI-OVN',
scriptName: 'SBI Overnight Fund', scriptName: 'SBI Overnight Fund',
instrumentType: 'MUTUAL_FUND', instrumentType: 'MUTUAL_FUND',
isin: 'INF200KA1Q40', isin: 'INF200KA1Q40',
faceValuePerUnit: 1000 faceValuePerUnit: 1000,
slrType: 'NON_SLR',
glCode: '100705',
companyName: 'SBI Mutual Fund',
city: 'Mumbai'
}, },
{ {
scriptCode: 'HDFC-LIQ', scriptCode: 'HDFC-LIQ',
scriptName: 'HDFC Liquid Fund', scriptName: 'HDFC Liquid Fund',
instrumentType: 'MUTUAL_FUND', instrumentType: 'MUTUAL_FUND',
isin: 'INF179KA1WR9', isin: 'INF179KA1WR9',
faceValuePerUnit: 1000 faceValuePerUnit: 1000,
slrType: 'NON_SLR',
glCode: '100705',
companyName: 'HDFC Mutual Fund',
city: 'Mumbai'
}, },
{ {
scriptCode: 'SBIN', scriptCode: 'SBIN',
scriptName: 'State Bank of India', scriptName: 'State Bank of India',
instrumentType: 'SHARE', instrumentType: 'SHARE',
isin: 'INE062A01020', isin: 'INE062A01020',
faceValuePerUnit: 1 faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100706'
}, },
{ {
scriptCode: 'HDFCBANK', scriptCode: 'HDFCBANK',
scriptName: 'HDFC Bank Ltd', scriptName: 'HDFC Bank Ltd',
instrumentType: 'SHARE', instrumentType: 'SHARE',
isin: 'INE040A01034', isin: 'INE040A01034',
faceValuePerUnit: 1 faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100706'
} }
]; ];
@@ -1561,23 +1964,164 @@ export const BROKER_MASTER: BrokerMaster[] = [
]; ];
export interface PartyMaster { export interface PartyMaster {
/** Backend id once the party is saved (absent on the sample rows). */
id?: number;
partyCode: string; partyCode: string;
partyName: string; partyName: string;
partyType: 'BANK' | 'CORPORATE' | 'GOVT' | 'AMC' | 'EXCHANGE' | 'COOP_SOCIETY'; partyType: 'BANK' | 'CORPORATE' | 'GOVT' | 'AMC' | 'EXCHANGE' | 'COOP_SOCIETY';
panNo: string; panNo?: string;
// Fields below follow the bank's existing CBS Bank / Party Master.
bondType?: InstrumentType;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
bankType?: string;
/** GL this party is mapped to. */
glAccountId?: number;
glCode?: string;
glName?: string;
/** Interest income GL of the FDs placed with this bank (Bond Type FD). */
interestGlAccountId?: number;
interestGlCode?: string;
interestGlName?: string;
/** Whether this bank deducts TDS from FD interest (some do, some do not), and at what rate (%). */
tdsApplicable?: boolean;
tdsRate?: number | null;
rbiCode?: string;
/** Exposure limit for this party (₹). */
limit?: number | null;
leiNo?: string;
// Counterparty's bank details
cpBankName?: string;
cpBranchName?: string;
cpAccountNo?: string;
cpIfsc?: string;
/** Share institutions (Bond Type SHARE): DCCB, State Co-op Bank, co-operative society or membership (NPCI, CCIL, ...). */
institutionType?: string;
/** Subscribed share capital of the institution (₹): our holding in one society is limited to 5% of it. */
subscribedCapital?: number | null;
/** Latest balance sheet received (DD-MM-YYYY). */
lastBalanceSheetDate?: string;
inLiquidation?: boolean;
/** Absent means ACTIVE. An INACTIVE party is not offered for new deals. */
status?: 'ACTIVE' | 'INACTIVE';
} }
/** Sample Bank Type options until the bank confirms its list. */
export const BANK_TYPE_OPTIONS = [
'SCHEDULED COMMERCIAL BANK',
'STATE CO-OPERATIVE BANK',
'DISTRICT CENTRAL CO-OPERATIVE BANK',
'URBAN CO-OPERATIVE BANK',
'RBI / GOVERNMENT',
'OTHER'
];
export const PARTY_MASTER: PartyMaster[] = [ export const PARTY_MASTER: PartyMaster[] = [
{ partyCode: 'PTY-001', partyName: 'Jila Sahakari Kendriya Bank', partyType: 'BANK', panNo: 'AABCJ1234K' }, {
{ partyCode: 'PTY-002', partyName: 'State Cooperative Bank Ltd', partyType: 'BANK', panNo: 'AABCS5678L' }, partyCode: 'PTY-001',
{ partyCode: 'PTY-003', partyName: 'RBI - SGL', partyType: 'GOVT', panNo: 'AAACR4849R' }, partyName: 'Jila Sahakari Kendriya Bank',
{ partyCode: 'PTY-004', partyName: 'REC Limited', partyType: 'CORPORATE', panNo: 'AAACR0641G' }, partyType: 'BANK',
{ partyCode: 'PTY-005', partyName: 'NABARD', partyType: 'CORPORATE', panNo: 'AAACN0272B' }, bondType: 'FD',
{ partyCode: 'PTY-006', partyName: 'L&T Finance Ltd', partyType: 'CORPORATE', panNo: 'AAACL0140P' }, glCode: '100704',
{ partyCode: 'PTY-007', partyName: 'SBI Mutual Fund', partyType: 'AMC', panNo: 'AAATS4325L' }, city: 'Vidisha',
{ partyCode: 'PTY-008', partyName: 'HDFC Mutual Fund', partyType: 'AMC', panNo: 'AAATH1234M' }, bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
{ partyCode: 'PTY-009', partyName: 'NSE/BSE', partyType: 'EXCHANGE', panNo: 'AAACN0028G' }, status: 'ACTIVE'
{ partyCode: 'PTY-010', partyName: 'District Cooperative Bank', partyType: 'COOP_SOCIETY', panNo: 'AABCD9988K' } },
{
partyCode: 'PTY-002',
partyName: 'State Cooperative Bank Ltd',
partyType: 'BANK',
bondType: 'FD',
glCode: '100704',
city: 'Bhopal',
bankType: 'STATE CO-OPERATIVE BANK',
status: 'ACTIVE'
},
{
partyCode: 'PTY-003',
partyName: 'RBI - SGL',
partyType: 'GOVT',
bondType: 'GOVT_SECURITY',
glCode: '100701',
city: 'Mumbai',
bankType: 'RBI / GOVERNMENT',
status: 'ACTIVE'
},
{
partyCode: 'PTY-004',
partyName: 'REC Limited',
partyType: 'CORPORATE',
bondType: 'NON_SLR',
glCode: '100703',
city: 'New Delhi',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-005',
partyName: 'NABARD',
partyType: 'CORPORATE',
bondType: 'NON_SLR',
glCode: '100703',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-006',
partyName: 'L&T Finance Ltd',
partyType: 'CORPORATE',
bondType: 'BOND',
glCode: '100703',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-007',
partyName: 'SBI Mutual Fund',
partyType: 'AMC',
bondType: 'MUTUAL_FUND',
glCode: '100705',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-008',
partyName: 'HDFC Mutual Fund',
partyType: 'AMC',
bondType: 'MUTUAL_FUND',
glCode: '100705',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-009',
partyName: 'NSE/BSE',
partyType: 'EXCHANGE',
bondType: 'SHARE',
glCode: '100706',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-010',
partyName: 'District Cooperative Bank',
partyType: 'COOP_SOCIETY',
bondType: 'SHARE',
glCode: '100706',
city: 'Vidisha',
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
status: 'ACTIVE'
}
]; ];
export interface SubTypeMaster { export interface SubTypeMaster {
@@ -1593,7 +2137,11 @@ export const SUBTYPE_MASTER: SubTypeMaster[] = [
{ subTypeCode: '4', subTypeName: 'EQUITY', instrumentType: 'SHARE' }, { subTypeCode: '4', subTypeName: 'EQUITY', instrumentType: 'SHARE' },
{ subTypeCode: '5', subTypeName: 'CO-OP SHARE', instrumentType: 'SHARE' }, { subTypeCode: '5', subTypeName: 'CO-OP SHARE', instrumentType: 'SHARE' },
{ subTypeCode: '6', subTypeName: 'DEBT - OVERNIGHT', instrumentType: 'MUTUAL_FUND' }, { subTypeCode: '6', subTypeName: 'DEBT - OVERNIGHT', instrumentType: 'MUTUAL_FUND' },
{ subTypeCode: '7', subTypeName: 'DEBT - LIQUID', instrumentType: 'MUTUAL_FUND' } { subTypeCode: '7', subTypeName: 'DEBT - LIQUID', instrumentType: 'MUTUAL_FUND' },
{ subTypeCode: '8', subTypeName: 'DEBT - MONEY MARKET', instrumentType: 'MUTUAL_FUND' },
{ subTypeCode: '9', subTypeName: 'DEBT - BANKING & PSU', instrumentType: 'MUTUAL_FUND' },
{ subTypeCode: '10', subTypeName: 'DEBT - CORPORATE BOND', instrumentType: 'MUTUAL_FUND' },
{ subTypeCode: '11', subTypeName: 'DEBT - GILT', instrumentType: 'MUTUAL_FUND' }
]; ];
export interface ControlParameter { export interface ControlParameter {
@@ -1725,13 +2273,15 @@ export const LIEN_REASON_MASTER: LienReason[] = [
/** Daily Script Master (legacy menu "09") — the day's quoted rate per script, entered/imported /** Daily Script Master (legacy menu "09") — the day's quoted rate per script, entered/imported
* every working day. Distinct from Script Market Detail ("13") which also carries YTM/valuation. */ * every working day. Distinct from Script Market Detail ("13") which also carries YTM/valuation. */
export interface DailyScriptRate { export interface DailyScriptRate {
/** Backend id once saved. */
id?: number;
rateDate: string; rateDate: string;
scriptCode: string; scriptCode: string;
scriptName: string; scriptName: string;
openRate: number; openRate: number | null;
closeRate: number; closeRate: number;
highRate: number; highRate: number | null;
lowRate: number; lowRate: number | null;
} }
export const DAILY_SCRIPT_RATES: DailyScriptRate[] = [ export const DAILY_SCRIPT_RATES: DailyScriptRate[] = [
@@ -0,0 +1,126 @@
<!--
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/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · DUE DATE DIARY</div>
<h1 class="pg-title">Due Date Diary</h1>
<p class="pg-sub">
Coupons, 31 Mar / 30 Sep provisions, premium amortisation, maturities and FD interest due in a period, with what
is posted and what is still pending.
</p>
</div>
</header>
<div class="pg-card">
<div class="inv-form-grid">
<label class="inv-form-field"
><span>From</span><mifosx-date-ddmmyyyy [(ngModel)]="fromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To</span><mifosx-date-ddmmyyyy [(ngModel)]="toDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Investment</span>
<select [(ngModel)]="instrument">
<option value="">All</option>
<option value="SECURITY">Govt Security / Bond</option>
<option value="FD">FD with banks</option>
</select>
</label>
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="status">
<option value="">All</option>
<option value="PENDING">Pending</option>
<option value="POSTED">Posted</option>
</select>
</label>
</div>
<div class="diary-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="setRange(7)">Next 7 days</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="setRange(30)">Next 30 days</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="setRange(90)">Next 90 days</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="showOverdue()">Overdue</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="loading" (click)="load()">
{{ loading ? 'Loading…' : 'Show' }}
</button>
</div>
@if (error) {
<p class="diary-error">{{ error }}</p>
}
</div>
@if (diary; as d) {
<div class="diary-summary">
<div class="diary-tile">
<span class="diary-tile-l">Entries</span><span class="diary-tile-v">{{ rows.length }}</span>
</div>
<div class="diary-tile">
<span class="diary-tile-l">Pending</span><span class="diary-tile-v">{{ d.pendingCount }}</span>
</div>
<div class="diary-tile" [class.diary-tile--bad]="d.overdueCount > 0">
<span class="diary-tile-l">Overdue</span><span class="diary-tile-v">{{ d.overdueCount }}</span>
</div>
<div class="diary-tile">
<span class="diary-tile-l">Money expected (not yet received)</span
><span class="diary-tile-v">₹{{ d.expectedInflow | number: '1.2-2' }}</span>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">{{ d.fromDate }} to {{ d.toDate }} · as on {{ d.asOnDate }}</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Due</th>
<th>Investment</th>
<th>Deal No</th>
<th>Particulars</th>
<th class="num">Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.instrument + r.refId + r.kind + r.date) {
<tr>
<td>{{ r.date }}</td>
<td>{{ kindLabels[r.kind] }}</td>
<td>{{ r.instrument === 'FD' ? 'FD' : 'Govt Security / Bond' }}</td>
<td>
<span class="pg-chip">{{ r.dealNo }}</span>
</td>
<td>{{ r.particulars }}</td>
<td class="num">
{{ r.amount !== null && r.amount !== undefined ? '₹' + (r.amount | number: '1.2-2') : '—' }}
</td>
<td>
<span class="inv-badge" [ngClass]="'diary-badge--' + r.status.toLowerCase()">{{ statusLabel(r) }}</span>
@if (r.voucherNo) {
<br /><small>{{ r.voucherNo }}</small>
}
</td>
<td>
@if (r.status !== 'POSTED') {
<a class="diary-open" [routerLink]="link(r).path" [queryParams]="link(r).query">Open →</a>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">Nothing falls due in this period.</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -0,0 +1,97 @@
/**
* 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/.
*/
.diary-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.diary-error {
margin: 8px 0 0;
color: #b91c1c;
}
.diary-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.diary-tile {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
&--bad {
border-color: #fecaca;
background: #fef2f2;
}
}
.diary-tile-l {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.diary-tile-v {
font-size: 22px;
font-weight: 700;
color: #0f172a;
}
.diary-badge--posted {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
.diary-badge--due {
background: #fff7ed;
color: #c2410c;
border: 1px solid #fed7aa;
}
.diary-badge--overdue {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
}
.diary-badge--upcoming {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.diary-open {
font-weight: 600;
white-space: nowrap;
text-decoration: none;
}
.report-total td {
font-weight: 700;
border-top: 2px solid #cbd5e1;
}
.report-pick {
cursor: pointer;
&--on td {
background: #eff6ff;
}
}
@@ -0,0 +1,122 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { addPeriod, todayDDMMYYYY } from '../investment-data';
import { InvestmentDiaryDto, InvestmentDiaryRowDto, InvestmentService, apiErrorMessage } from '../investment.service';
type InstrumentFilter = '' | 'SECURITY' | 'FD';
type StatusFilter = '' | 'PENDING' | 'POSTED';
/**
* Due Date Diary for All Investment: coupons, 31 Mar / 30 Sep provisions, premium amortisation, maturities and FD
* payouts in a period, with what is posted, due or overdue, and a link to the screen that posts each one.
*/
@Component({
selector: 'mifosx-investment-diary',
templateUrl: './investment-diary.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss',
'./investment-diary.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
DateDdmmyyyyPickerComponent
]
})
export class InvestmentDiaryComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
// Opens on everything still to be done: what is pending from the past year and what falls due in the next 30 days.
fromDate = addPeriod(todayDDMMYYYY(), -1, 0, 0);
toDate = addPeriod(todayDDMMYYYY(), 0, 0, 30);
instrument: InstrumentFilter = '';
status: StatusFilter = 'PENDING';
diary: InvestmentDiaryDto | null = null;
loading = false;
error = '';
readonly kindLabels: Record<InvestmentDiaryRowDto['kind'], string> = {
COUPON: 'Coupon',
PROVISION: 'Interest provision',
MATURITY: 'Maturity / redemption',
AMORTISATION: 'Premium amortisation',
FD_PROVISION: 'FD interest provision',
FD_PAYOUT: 'FD interest payout',
FD_MATURITY: 'FD maturity'
};
ngOnInit(): void {
this.load();
}
/** Quick ranges from today. */
setRange(days: number): void {
this.status = '';
this.fromDate = todayDDMMYYYY();
this.toDate = addPeriod(this.fromDate, 0, 0, days);
this.load();
}
/** What is still pending from the past (up to a year back) and today. */
showOverdue(): void {
this.toDate = todayDDMMYYYY();
this.fromDate = addPeriod(this.toDate, -1, 0, 0);
this.status = 'PENDING';
this.load();
}
load(): void {
if (!this.fromDate || !this.toDate) {
this.error = 'Enter the From and To dates.';
return;
}
this.loading = true;
this.error = '';
this.investmentService.getDiary(this.fromDate, this.toDate).subscribe({
next: (d) => {
this.diary = d;
this.loading = false;
},
error: (err) => {
this.diary = null;
this.loading = false;
this.error = apiErrorMessage(err);
}
});
}
get rows(): InvestmentDiaryRowDto[] {
return (this.diary?.rows ?? []).filter(
(r) =>
(!this.instrument || r.instrument === this.instrument) &&
(!this.status || (this.status === 'POSTED' ? r.status === 'POSTED' : r.status !== 'POSTED'))
);
}
/** The screen that posts this entry. */
link(r: InvestmentDiaryRowDto): { path: string; query: Record<string, string | number> } {
if (r.instrument === 'FD') {
return { path: '/investment/fd', query: { tab: 'interest', fd: r.refId } };
}
const tab = r.kind === 'MATURITY' ? 'redemption' : r.kind === 'AMORTISATION' ? 'amortize' : 'interest';
return { path: '/investment/bonds', query: { tab, purchase: r.refId } };
}
statusLabel(r: InvestmentDiaryRowDto): string {
return r.status === 'DUE' ? 'Due today' : r.status.charAt(0) + r.status.slice(1).toLowerCase();
}
}
@@ -0,0 +1,125 @@
<!--
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/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
<h1 class="pg-title">Total Interest Received &amp; Receivable</h1>
<p class="pg-sub">
Interest received from Govt Securities, bonds and FDs in the period, and interest earned up to the To date that
has not come in yet.
</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="!report || generating" (click)="exportExcel()">
{{ generating ? 'Generating…' : 'Export to Excel' }}
</button>
</header>
<div class="pg-card">
<div class="inv-form-grid">
<label class="inv-form-field"
><span>From</span><mifosx-date-ddmmyyyy [(ngModel)]="fromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To (receivable as on)</span><mifosx-date-ddmmyyyy [(ngModel)]="toDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Investment</span>
<select [(ngModel)]="instrument">
<option value="">All</option>
<option value="SECURITY">Govt Security / Bond</option>
<option value="FD">FD with banks</option>
</select>
</label>
</div>
<div class="diary-actions">
<button type="button" class="pg-btn pg-btn--primary" [disabled]="loading" (click)="load()">
{{ loading ? 'Loading…' : 'Show' }}
</button>
</div>
@if (error) {
<p class="diary-error">{{ error }}</p>
}
</div>
@if (report; as r) {
<div class="diary-summary">
<div class="diary-tile">
<span class="diary-tile-l">Received</span
><span class="diary-tile-v">₹{{ totals.received | number: '1.2-2' }}</span>
</div>
<div class="diary-tile">
<span class="diary-tile-l">Receivable</span
><span class="diary-tile-v">₹{{ totals.receivable | number: '1.2-2' }}</span>
</div>
<div class="diary-tile">
<span class="diary-tile-l">Of which provisioned (100603)</span
><span class="diary-tile-v">₹{{ totals.provisioned | number: '1.2-2' }}</span>
</div>
<div class="diary-tile">
<span class="diary-tile-l">Total</span><span class="diary-tile-v">₹{{ totals.total | number: '1.2-2' }}</span>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">
{{ r.fromDate }} to {{ r.toDate }} · received in the period, receivable as on {{ r.toDate }}
</div>
<table class="pg-table">
<thead>
<tr>
<th>Investment</th>
<th>Deal No</th>
<th>Particulars</th>
<th>Status</th>
<th class="num">Principal / Face Value</th>
<th class="num">Rate</th>
<th class="num col-in">Received</th>
<th class="num">Receivable</th>
<th class="num">Of which provisioned</th>
<th class="num">Total</th>
</tr>
</thead>
<tbody>
@for (x of rows; track x.instrument + x.refId) {
<tr>
<td>{{ x.instrument === 'FD' ? 'FD' : 'Govt Security / Bond' }}</td>
<td>
<span class="pg-chip">{{ x.dealNo }}</span>
</td>
<td>{{ x.particulars }}</td>
<td>{{ x.status }}</td>
<td class="num">₹{{ x.principal | number: '1.2-2' }}</td>
<td class="num">{{ x.rate !== null && x.rate !== undefined ? x.rate + '%' : '—' }}</td>
<td class="num col-in">₹{{ x.received | number: '1.2-2' }}</td>
<td class="num">₹{{ x.receivable | number: '1.2-2' }}</td>
<td class="num">₹{{ x.provisioned | number: '1.2-2' }}</td>
<td class="num">₹{{ x.total | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="pg-empty">No interest received or receivable in this period.</td>
</tr>
}
</tbody>
@if (rows.length) {
<tfoot>
<tr class="report-total">
<td colspan="6">Total</td>
<td class="num col-in">₹{{ totals.received | number: '1.2-2' }}</td>
<td class="num">₹{{ totals.receivable | number: '1.2-2' }}</td>
<td class="num">₹{{ totals.provisioned | number: '1.2-2' }}</td>
<td class="num">₹{{ totals.total | number: '1.2-2' }}</td>
</tr>
</tfoot>
}
</table>
</div>
}
</div>
@@ -0,0 +1,173 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { parseDDMMYYYY, todayDDMMYYYY } from '../investment-data';
import { exportWorkbook } from '../investment-export';
import {
InvestmentInterestReportDto,
InvestmentInterestReportRowDto,
InvestmentService,
apiErrorMessage
} from '../investment.service';
type InstrumentFilter = '' | 'SECURITY' | 'FD';
/**
* Total Interest Received & Receivable: per Govt Security / bond deal and FD, the interest received in a period and the
* interest earned up to its last day that has not come in yet, with the part already provisioned in the books.
*/
@Component({
selector: 'mifosx-investment-interest-report',
templateUrl: './investment-interest-report.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss',
'../investment-diary/investment-diary.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class InvestmentInterestReportComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
/** Defaults to the current financial year, 1 April to today. */
fromDate = InvestmentInterestReportComponent.financialYearStart();
toDate = todayDDMMYYYY();
instrument: InstrumentFilter = '';
report: InvestmentInterestReportDto | null = null;
loading = false;
generating = false;
error = '';
ngOnInit(): void {
this.load();
}
private static financialYearStart(): string {
const d = parseDDMMYYYY(todayDDMMYYYY());
const year = d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
return `01-04-${year}`;
}
load(): void {
if (!this.fromDate || !this.toDate) {
this.error = 'Enter the From and To dates.';
return;
}
this.loading = true;
this.error = '';
this.investmentService.getInterestReport(this.fromDate, this.toDate).subscribe({
next: (r) => {
this.report = r;
this.loading = false;
},
error: (err) => {
this.report = null;
this.loading = false;
this.error = apiErrorMessage(err);
}
});
}
get rows(): InvestmentInterestReportRowDto[] {
return (this.report?.rows ?? []).filter((r) => !this.instrument || r.instrument === this.instrument);
}
/** Totals of the rows shown, so they follow the Investment filter. */
get totals(): { received: number; receivable: number; provisioned: number; total: number } {
return this.rows.reduce(
(t, r) => ({
received: t.received + r.received,
receivable: t.receivable + r.receivable,
provisioned: t.provisioned + r.provisioned,
total: t.total + r.total
}),
{ received: 0, receivable: 0, provisioned: 0, total: 0 }
);
}
async exportExcel(): Promise<void> {
if (!this.report) {
return;
}
this.generating = true;
try {
const t = this.totals;
await exportWorkbook(
[
{
sheetName: 'Interest Received',
title: 'Total Interest Received & Receivable',
subtitle: `${this.report.fromDate} to ${this.report.toDate}`,
headers: [
'Investment',
'Deal No',
'Particulars',
'Status',
'Principal / Face Value',
'Rate %',
'Received',
'Receivable',
'Of which provisioned',
'Total'
],
widths: [
14,
16,
40,
12,
18,
8,
16,
16,
18,
16
],
rows: [
...this.rows.map((r) => [
r.instrument === 'FD' ? 'FD' : 'Govt Security / Bond',
r.dealNo,
r.particulars,
r.status,
r.principal,
r.rate ?? null,
r.received,
r.receivable,
r.provisioned,
r.total
]),
[
'Total',
'',
'',
'',
null,
null,
t.received,
t.receivable,
t.provisioned,
t.total
]
]
}
],
`Interest-Received-Receivable-${this.report.toDate}`
);
} finally {
this.generating = false;
}
}
}
@@ -106,38 +106,163 @@
</button> </button>
</div> </div>
<!-- PARTY MASTER --> <!-- PARTY MASTER (layout follows the bank's existing CBS Bank / Party Master) -->
@if (activeTab === 'party') { @if (activeTab === 'party') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Add Bank / Party</div> <div class="pos-section-label">Bank / Party Master — Entry</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"
><span>Party Name *</span><input type="text" [(ngModel)]="newParty.partyName"
/></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Party Type *</span> <span>* Bond Type</span>
<select [(ngModel)]="newParty.partyType"> <select [(ngModel)]="partyForm.bondType">
<option [ngValue]="undefined">-- Select --</option> <option [ngValue]="undefined">-- Select --</option>
<option value="BANK">BANK</option> @for (t of instrumentTypes; track t) {
<option value="CORPORATE">CORPORATE</option> <option [value]="t">{{ t }}</option>
<option value="GOVT">GOVT</option> }
<option value="AMC">AMC</option>
<option value="EXCHANGE">EXCHANGE</option>
<option value="COOP_SOCIETY">COOP SOCIETY</option>
</select> </select>
</label> </label>
<label class="inv-form-field"><span>PAN No</span><input type="text" [(ngModel)]="newParty.panNo" /></label> <label class="inv-form-field"
><span>* Party Code</span><input type="text" [value]="partyForm.partyCode || nextPartyCode" readonly
/></label>
<label class="inv-form-field"
><span>* Party Name</span><input type="text" [(ngModel)]="partyForm.partyName"
/></label>
<label class="inv-form-field"
><span>Address Line 1 (Building / Flat No.)</span
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="partyForm.address1"
/></label>
<label class="inv-form-field"
><span>Address Line 2 (Street / Area)</span
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="partyForm.address2"
/></label>
<label class="inv-form-field"
><span>Address Line 3 (Locality / Landmark)</span
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="partyForm.address3"
/></label>
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="partyForm.city" /></label>
<label class="inv-form-field"
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="partyForm.pincode"
/></label>
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="partyForm.phone" /></label>
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="partyForm.fax" /></label>
<label class="inv-form-field">
<span>Bank Type</span>
<select [(ngModel)]="partyForm.bankType">
<option [ngValue]="undefined">-- Select --</option>
@for (b of bankTypeOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* GL A/c No.</span>
<select [(ngModel)]="partyForm.glAccountId">
<option [ngValue]="undefined">-- Select --</option>
@for (g of partyGlOptions; track g.id) {
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
}
</select>
</label>
@if (partyForm.bondType === 'FD') {
<label class="inv-form-field">
<span>* Interest GL</span>
<select [(ngModel)]="partyForm.interestGlAccountId">
<option [ngValue]="undefined">-- Select --</option>
@for (g of interestGlOptions; track g.id) {
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Bank deducts TDS on FD interest?</span>
<select [ngModel]="partyForm.tdsApplicable ?? false" (ngModelChange)="partyForm.tdsApplicable = $event">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
@if (partyForm.tdsApplicable) {
<label class="inv-form-field"
><span>* TDS rate (%)</span
><input type="number" min="0" max="99" step="0.01" [(ngModel)]="partyForm.tdsRate" placeholder="e.g. 10"
/></label>
}
}
@if (partyForm.bondType === 'SHARE') {
<label class="inv-form-field">
<span>* Institution Type</span>
<select [(ngModel)]="partyForm.institutionType">
<option [ngValue]="undefined">-- Select --</option>
@for (t of shareInstitutionTypes; track t.value) {
<option [value]="t.value">{{ t.label }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span
>{{ partyForm.institutionType === 'OTHER_COOP_SOCIETY' ? '* ' : '' }}Subscribed Share Capital (₹)</span
><input type="number" min="0" [(ngModel)]="partyForm.subscribedCapital"
/></label>
<label class="inv-form-field"
><span>Last Balance Sheet Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="partyForm.lastBalanceSheetDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>In Liquidation?</span>
<select [ngModel]="partyForm.inLiquidation ?? false" (ngModelChange)="partyForm.inLiquidation = $event">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
}
<label class="inv-form-field"><span>RBI Code</span><input type="text" [(ngModel)]="partyForm.rbiCode" /></label>
<label class="inv-form-field"
><span>Limit (₹)</span><input type="number" min="0" [(ngModel)]="partyForm.limit"
/></label>
<label class="inv-form-field"><span>LEI No</span><input type="text" [(ngModel)]="partyForm.leiNo" /></label>
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="partyForm.status">
<option [ngValue]="undefined">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
</select>
</label>
</div>
<div class="pos-section-label">Counterparty's Bank Details</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Name of Bank</span><input type="text" [(ngModel)]="partyForm.cpBankName"
/></label>
<label class="inv-form-field"
><span>Name of Branch</span><input type="text" [(ngModel)]="partyForm.cpBranchName"
/></label>
<label class="inv-form-field"
><span>Account No.</span><input type="text" [(ngModel)]="partyForm.cpAccountNo"
/></label>
<label class="inv-form-field"
><span>IFSC</span><input type="text" maxlength="11" [(ngModel)]="partyForm.cpIfsc"
/></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addParty()">Save</button> <label class="inv-form-field"
><span>Search Party Code</span><input type="text" placeholder="e.g. PTY-001" [(ngModel)]="partySearchCode"
/></label>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveParty()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchParty()">Search</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Cancel</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropParty()">Drop</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Exit</button>
</div> </div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Party Code</th> <th>Party Code</th>
<th>Bond Type</th>
<th>Party Name</th> <th>Party Name</th>
<th>Type</th> <th>City</th>
<th>PAN No</th> <th>Bank Type</th>
<th>GL A/c</th>
<th class="num">Limit</th>
<th>Counterparty Bank / IFSC</th>
<th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -146,9 +271,19 @@
<td> <td>
<span class="pg-chip">{{ p.partyCode }}</span> <span class="pg-chip">{{ p.partyCode }}</span>
</td> </td>
<td>{{ p.partyName }}</td> <td>{{ p.bondType || '—' }}</td>
<td>{{ p.partyType }}</td> <td>
<td>{{ p.panNo }}</td> {{ p.partyName }}
@if (p.institutionType) {
<small>({{ p.institutionType }}{{ p.inLiquidation ? ', in liquidation' : '' }})</small>
}
</td>
<td>{{ p.city || '—' }}</td>
<td>{{ p.bankType || '—' }}</td>
<td>{{ p.glCode ? p.glCode + ' — ' + glName(p.glCode) : '—' }}</td>
<td class="num">{{ (p.limit ?? null) !== null ? '₹' + (p.limit | number: '1.0-0') : '—' }}</td>
<td>{{ p.cpBankName ? p.cpBankName + (p.cpIfsc ? ' / ' + p.cpIfsc : '') : '—' }}</td>
<td>{{ p.status ?? 'ACTIVE' }}</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -156,42 +291,180 @@
</div> </div>
} }
<!-- SCRIPT MASTER --> <!-- SCRIPT MASTER (layout follows the bank's existing CBS Script Master) -->
@if (activeTab === 'script') { @if (activeTab === 'script') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Add Script</div> <div class="pos-section-label">Script Master — Entry</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"
><span>Script Code</span><input type="text" [(ngModel)]="newScript.scriptCode"
/></label>
<label class="inv-form-field"
><span>Script Name *</span><input type="text" [(ngModel)]="newScript.scriptName"
/></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Instrument Type *</span> <span>* Bond Type</span>
<select [(ngModel)]="newScript.instrumentType"> <select [(ngModel)]="scriptForm.instrumentType" [disabled]="!!scriptForm.scriptCode">
<option [ngValue]="undefined">-- Select --</option> <option [ngValue]="undefined">-- Select --</option>
@for (t of instrumentTypes; track t) { @for (t of instrumentTypes; track t) {
<option [value]="t">{{ t }}</option> <option [value]="t">{{ t }}</option>
} }
</select> </select>
</label> </label>
<label class="inv-form-field"><span>ISIN</span><input type="text" [(ngModel)]="newScript.isin" /></label> <label class="inv-form-field">
<span>SLR</span>
<select [(ngModel)]="scriptForm.slrType">
<option [ngValue]="undefined">-- Select --</option>
@for (o of slrTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Face Value / Unit</span><input type="number" [(ngModel)]="newScript.faceValuePerUnit" ><span>* Script Code</span><input type="text" [value]="scriptForm.scriptCode || nextScriptCode" readonly
/></label>
<label class="inv-form-field"
><span>* Script Desc</span
><input type="text" placeholder="e.g. 7.50%GOI2034" [(ngModel)]="scriptForm.scriptName"
/></label>
<label class="inv-form-field">
<span>* GL A/c No.</span>
<select [(ngModel)]="scriptForm.glAccountId">
<option [ngValue]="undefined">-- Select --</option>
@for (g of scriptGlOptions; track g.id) {
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}ISIN</span><input type="text" [(ngModel)]="scriptForm.isin"
/></label>
<label class="inv-form-field"
><span>{{ scriptForm.instrumentType === 'SHARE' ? 'Face Value per Share' : 'Face Value per Unit' }}</span
><input type="number" min="0" step="0.01" placeholder="100" [(ngModel)]="scriptForm.faceValuePerUnit"
/></label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Int Rate %</span
><input type="number" step="0.01" min="0" [(ngModel)]="scriptForm.couponRate"
/></label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Issue Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Redum / Mat Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.maturityDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Period (Year)</span><input type="text" [value]="scriptPeriod.years" readonly
/></label>
<label class="inv-form-field"
><span>Period (Month)</span><input type="text" [value]="scriptPeriod.months" readonly
/></label>
<label class="inv-form-field"
><span>Period (Days)</span><input type="text" [value]="scriptPeriod.days" readonly
/></label> /></label>
</div> </div>
@if (scriptIsRated) {
<div class="pos-section-label">Credit Rating (RBI: bonds "A" or higher, CP / CD "A2" or higher)</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>* Rating</span>
<select [(ngModel)]="scriptForm.rating">
<option [ngValue]="undefined">-- Select --</option>
@for (g of ratingOptions; track g.group) {
<optgroup [label]="g.group">
@for (r of g.values; track r) {
<option [value]="r">{{ r }}</option>
}
</optgroup>
}
</select>
</label>
<label class="inv-form-field">
<span>Rating Agency</span>
<select [(ngModel)]="scriptForm.ratingAgency">
<option [ngValue]="undefined">-- Select --</option>
@for (a of ratingAgencyOptions; track a.value) {
<option [value]="a.value">{{ a.label }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Listing</span>
<select
[ngModel]="scriptForm.listed === false ? 'UNLISTED' : 'LISTED'"
(ngModelChange)="scriptForm.listed = $event !== 'UNLISTED'"
>
<option value="LISTED">Listed / proposed to be listed</option>
<option value="UNLISTED">Unlisted</option>
</select>
</label>
</div>
@if (scriptForm.rating) {
<p class="inv-hint" [class.inv-hint--bad]="!scriptRatingEligible">
{{
scriptRatingEligible
? 'Rating meets the RBI minimum; this security can be bought.'
: 'Below the RBI minimum rating, or the wrong scale (CP / CD up to one year: A1+ to A4; bond: AAA to D).'
}}
</p>
}
}
<div class="pos-section-label">Company Details (Bond / Mutual Fund)</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Company Name</span><input type="text" [(ngModel)]="scriptForm.companyName"
/></label>
<label class="inv-form-field"
><span>Address Line 1 (Building / Flat No.)</span
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="scriptForm.address1"
/></label>
<label class="inv-form-field"
><span>Address Line 2 (Street / Area)</span
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="scriptForm.address2"
/></label>
<label class="inv-form-field"
><span>Address Line 3 (Locality / Landmark)</span
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="scriptForm.address3"
/></label>
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="scriptForm.city" /></label>
<label class="inv-form-field"
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="scriptForm.pincode"
/></label>
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="scriptForm.phone" /></label>
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="scriptForm.fax" /></label>
<label class="inv-form-field"
><span>Corp. Code</span><input type="text" [(ngModel)]="scriptForm.corpCode"
/></label>
<label class="inv-form-field">
<span>Status</span>
<select
[ngModel]="scriptForm.active === false ? 'INACTIVE' : 'ACTIVE'"
(ngModelChange)="scriptForm.active = $event !== 'INACTIVE'"
>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
</select>
</label>
</div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addScript()">Save</button> <label class="inv-form-field"
><span>Search Script Code</span><input type="text" placeholder="e.g. G00001" [(ngModel)]="scriptSearchCode"
/></label>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveScript()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchScript()">Search</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Cancel</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Exit</button>
</div> </div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Script Code</th> <th>Script Code</th>
<th>Script Name</th> <th>Bond Type</th>
<th>Instrument</th> <th>SLR</th>
<th>Script Desc</th>
<th>GL A/c</th>
<th>ISIN</th> <th>ISIN</th>
<th class="num">Face Value/Unit</th> <th class="num">Int Rate %</th>
<th>Issue Date</th>
<th>Maturity</th>
<th>Rating</th>
<th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -200,10 +473,16 @@
<td> <td>
<span class="pg-chip">{{ s.scriptCode }}</span> <span class="pg-chip">{{ s.scriptCode }}</span>
</td> </td>
<td>{{ s.scriptName }}</td>
<td>{{ s.instrumentType }}</td> <td>{{ s.instrumentType }}</td>
<td>{{ s.slrType || '—' }}</td>
<td>{{ s.scriptName }}</td>
<td>{{ s.glCode || '—' }}</td>
<td>{{ s.isin || '—' }}</td> <td>{{ s.isin || '—' }}</td>
<td class="num">{{ s.faceValuePerUnit }}</td> <td class="num">{{ s.couponRate ?? '—' }}</td>
<td>{{ s.issueDate || '—' }}</td>
<td>{{ s.maturityDate || '—' }}</td>
<td>{{ s.rating ? s.rating + (s.listed === false ? ' · Unlisted' : '') : '—' }}</td>
<td>{{ s.active === false ? 'INACTIVE' : 'ACTIVE' }}</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -361,21 +640,36 @@
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Open Rate</span><input type="number" [(ngModel)]="newDailyRate.openRate" step="0.01" ><span>Open Rate</span><input type="number" [(ngModel)]="newDailyRate.openRate" step="0.0001"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Close Rate *</span><input type="number" [(ngModel)]="newDailyRate.closeRate" step="0.01" ><span>Close Rate *</span><input type="number" [(ngModel)]="newDailyRate.closeRate" step="0.0001"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>High Rate</span><input type="number" [(ngModel)]="newDailyRate.highRate" step="0.01" ><span>High Rate</span><input type="number" [(ngModel)]="newDailyRate.highRate" step="0.0001"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Low Rate</span><input type="number" [(ngModel)]="newDailyRate.lowRate" step="0.01" ><span>Low Rate</span><input type="number" [(ngModel)]="newDailyRate.lowRate" step="0.0001"
/></label> /></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addDailyRate()">Save</button> <button type="button" class="pg-btn pg-btn--primary" (click)="addDailyRate()">Save</button>
<label class="pg-btn pg-btn--ghost">
{{ uploading ? 'Uploading…' : 'Upload CSV / Excel' }}
<input type="file" accept=".csv,.xlsx" hidden [disabled]="uploading" (change)="uploadDailyRates($event)" />
</label>
</div> </div>
<p class="inv-hint">
File columns: Script Code (or ISIN), Rate Date (DD-MM-YYYY), Close, Open, High, Low — first row is the header.
Use it for the day's market rates and mutual fund NAVs.
</p>
@if (uploadResults.length) {
<div class="inv-form-grid">
@for (r of uploadResults; track r.row) {
<p class="inv-hint" [class.inv-hint--bad]="!r.ok">Row {{ r.row }}: {{ r.text }}</p>
}
</div>
}
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
@@ -386,20 +680,29 @@
<th class="num">High</th> <th class="num">High</th>
<th class="num">Low</th> <th class="num">Low</th>
<th class="num">Close</th> <th class="num">Close</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (r of dailyScriptRates; track r.rateDate + r.scriptCode) { @for (r of dailyScriptRates; track r.id ?? r.rateDate + r.scriptCode) {
<tr> <tr>
<td>{{ r.rateDate }}</td> <td>{{ r.rateDate }}</td>
<td> <td>
<span class="pg-chip">{{ r.scriptCode }}</span> <span class="pg-chip">{{ r.scriptCode }}</span>
</td> </td>
<td>{{ r.scriptName }}</td> <td>{{ r.scriptName }}</td>
<td class="num">{{ r.openRate | number: '1.2-2' }}</td> <!-- Mutual fund NAVs run to 4 decimals. -->
<td class="num">{{ r.highRate | number: '1.2-2' }}</td> <td class="num">{{ r.openRate | number: '1.2-4' }}</td>
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td> <td class="num">{{ r.highRate | number: '1.2-4' }}</td>
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td> <td class="num">{{ r.lowRate | number: '1.2-4' }}</td>
<td class="num">{{ r.closeRate | number: '1.2-4' }}</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropDailyRate(r)">Drop</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No rates entered yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -6,20 +6,18 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component'; import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { import {
SCRIPT_MASTER,
BROKER_MASTER, BROKER_MASTER,
PARTY_MASTER,
SUBTYPE_MASTER, SUBTYPE_MASTER,
CONTROL_PARAMETERS, CONTROL_PARAMETERS,
MUTUAL_FUND_PRODUCTS, MUTUAL_FUND_PRODUCTS,
SGL_BANK_MASTER, SGL_BANK_MASTER,
SCRIPT_MARKET_DETAIL, SCRIPT_MARKET_DETAIL,
DAILY_SCRIPT_RATES,
LIEN_REASON_MASTER, LIEN_REASON_MASTER,
ScriptMaster, ScriptMaster,
BrokerMaster, BrokerMaster,
@@ -31,9 +29,26 @@ import {
ScriptMarketDetail, ScriptMarketDetail,
DailyScriptRate, DailyScriptRate,
LienReason, LienReason,
InstrumentType InstrumentType,
BANK_TYPE_OPTIONS,
SLR_TYPE_OPTIONS,
INVESTMENT_GL_LOOKUP,
ELIGIBLE_RATINGS,
parseDDMMYYYY,
RATING_AGENCY_OPTIONS,
RATING_OPTIONS,
periodBetween
} from '../investment-data'; } from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export'; import { exportWorkbook, ExportSheetSpec } from '../investment-export';
import {
InvestmentGlAccount,
InvestmentService,
SHARE_INSTITUTION_TYPES,
apiErrorMessage,
awaitingApproval,
sentForApproval,
toDailyScriptRate
} from '../investment.service';
type MasterTab = type MasterTab =
| 'party' | 'party'
@@ -51,6 +66,10 @@ type MasterTab =
* Investment master-data screens (legacy "04-13" Entry submenu masters) — 9 simple dummy * Investment master-data screens (legacy "04-13" Entry submenu masters) — 9 simple dummy
* lists, each with an inline Add-row form so the demo stays interactive without a backend. * lists, each with an inline Add-row form so the demo stays interactive without a backend.
*/ */
/** Detail GLs under the 100700 INVESTMENTS head; 100707 holds premium only, never a party's or script's GL. */
const INVESTMENT_GL_HEAD_PREFIX = '1007';
const PREMIUM_ON_INVESTMENTS_GL_CODE = '100707';
@Component({ @Component({
selector: 'mifosx-investment-masters', selector: 'mifosx-investment-masters',
templateUrl: './investment-masters.component.html', templateUrl: './investment-masters.component.html',
@@ -65,18 +84,19 @@ type MasterTab =
DateDdmmyyyyPickerComponent DateDdmmyyyyPickerComponent
] ]
}) })
export class InvestmentMastersComponent { export class InvestmentMastersComponent implements OnInit {
activeTab: MasterTab = 'party'; activeTab: MasterTab = 'party';
scripts: ScriptMaster[] = [...SCRIPT_MASTER]; scripts: ScriptMaster[] = [];
brokers: BrokerMaster[] = [...BROKER_MASTER]; brokers: BrokerMaster[] = [...BROKER_MASTER];
parties: PartyMaster[] = [...PARTY_MASTER]; parties: PartyMaster[] = [];
subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER]; subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER];
controlParams: ControlParameter[] = [...CONTROL_PARAMETERS]; controlParams: ControlParameter[] = [...CONTROL_PARAMETERS];
mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS]; mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS];
sglBanks: SglBankMaster[] = [...SGL_BANK_MASTER]; sglBanks: SglBankMaster[] = [...SGL_BANK_MASTER];
marketDetails: ScriptMarketDetail[] = [...SCRIPT_MARKET_DETAIL]; marketDetails: ScriptMarketDetail[] = [...SCRIPT_MARKET_DETAIL];
dailyScriptRates: DailyScriptRate[] = [...DAILY_SCRIPT_RATES]; /** Loaded from the backend (Daily Script Master) in ngOnInit. */
dailyScriptRates: DailyScriptRate[] = [];
lienReasons: LienReason[] = [...LIEN_REASON_MASTER]; lienReasons: LienReason[] = [...LIEN_REASON_MASTER];
readonly instrumentTypes: InstrumentType[] = [ readonly instrumentTypes: InstrumentType[] = [
@@ -106,8 +126,24 @@ export class InvestmentMastersComponent {
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
newParty: Partial<PartyMaster> = {}; // Bank / Party Master and Script Master follow the bank's existing CBS screens (one entry form
newScript: Partial<ScriptMaster> = {}; // with Save / Search / Cancel / Drop / Exit) and are stored in the backend through
// InvestmentService; the other master tabs are still sample data.
private readonly investmentService = inject(InvestmentService);
partyForm: Partial<PartyMaster> = {};
partySearchCode = '';
scriptForm: Partial<ScriptMaster> = {};
scriptSearchCode = '';
saving = false;
readonly bankTypeOptions = BANK_TYPE_OPTIONS;
readonly slrTypeOptions = SLR_TYPE_OPTIONS;
readonly ratingOptions = RATING_OPTIONS;
readonly ratingAgencyOptions = RATING_AGENCY_OPTIONS;
readonly shareInstitutionTypes = SHARE_INSTITUTION_TYPES;
/** Investment GLs (under the 100700 INVESTMENTS head) a party or script can be mapped to — not loans, banks or test GLs. */
investmentGlOptions: InvestmentGlAccount[] = [];
/** Income GLs under 400400 INCOME FROM INVESTMENTS, for an FD bank's interest. */
interestGlOptions: InvestmentGlAccount[] = [];
newSubType: Partial<SubTypeMaster> = {}; newSubType: Partial<SubTypeMaster> = {};
newBroker: Partial<BrokerMaster> = {}; newBroker: Partial<BrokerMaster> = {};
newControlParam: Partial<ControlParameter> = {}; newControlParam: Partial<ControlParameter> = {};
@@ -116,43 +152,312 @@ export class InvestmentMastersComponent {
newSglBank: Partial<SglBankMaster> = {}; newSglBank: Partial<SglBankMaster> = {};
newDailyRate: Partial<DailyScriptRate> = {}; newDailyRate: Partial<DailyScriptRate> = {};
ngOnInit(): void {
this.loadDailyRates();
this.investmentService.getAssetGlAccounts().subscribe({
next: (gls) =>
(this.investmentGlOptions = gls.filter(
(g) => g.glCode.startsWith(INVESTMENT_GL_HEAD_PREFIX) && g.glCode !== PREMIUM_ON_INVESTMENTS_GL_CODE
)),
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
});
this.investmentService.getIncomeGlAccounts().subscribe({
next: (gls) => (this.interestGlOptions = gls.filter((g) => g.glCode.startsWith('4004'))),
error: (err) => this.toast(`Could not load income GLs: ${apiErrorMessage(err)}`)
});
this.reloadMasters();
}
setTab(tab: MasterTab): void { setTab(tab: MasterTab): void {
this.activeTab = tab; this.activeTab = tab;
} }
addParty(): void { /** Re-reads both masters from the backend (also refreshes the lists the Purchase screen uses). */
if (!this.newParty.partyName || !this.newParty.partyType) { private reloadMasters(): void {
this.toast('Enter Party Name and Type.'); this.investmentService.syncSharedMasters().subscribe({
return; next: ({ parties, scripts }) => {
} this.parties = parties;
this.parties = [ this.scripts = scripts;
{ },
partyCode: `PTY-${String(this.parties.length + 1).padStart(3, '0')}`, error: (err) => this.toast(`Could not load masters: ${apiErrorMessage(err)}`)
panNo: '', });
...this.newParty
} as PartyMaster,
...this.parties
];
this.newParty = {};
this.toast('Party/Bank master added.');
} }
addScript(): void { glName(glCode?: string): string {
if (!this.newScript.scriptName || !this.newScript.instrumentType) { return (
this.toast('Enter Script Name and Instrument Type.'); this.investmentGlOptions.find((g) => g.glCode === glCode)?.name ??
INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ??
''
);
}
/** FD GLs are the bank-wise children of 100704 (codes 1007040x). */
private static isFdGl(glCode: string): boolean {
return glCode.startsWith('1007040');
}
/** An FD bank is mapped to its own FD GL; any other party to a non-FD investment GL. */
get partyGlOptions(): InvestmentGlAccount[] {
const fd = this.partyForm.bondType === 'FD';
return this.investmentGlOptions.filter((g) => InvestmentMastersComponent.isFdGl(g.glCode) === fd);
}
/** Scripts are securities, never an FD with a bank. */
get scriptGlOptions(): InvestmentGlAccount[] {
return this.investmentGlOptions.filter((g) => !InvestmentMastersComponent.isFdGl(g.glCode));
}
// -------------------- Bank / Party Master --------------------
/** Preview of the code the backend will assign; the saved code always comes from the backend. */
get nextPartyCode(): string {
const nums = this.parties.map((p) => parseInt(p.partyCode.replace(/\D/g, ''), 10)).filter((n) => !isNaN(n));
return `PTY-${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(3, '0')}`;
}
saveParty(): void {
const f = this.partyForm;
if (!f.bondType || !f.partyName || !f.glAccountId) {
this.toast('Enter Bond Type, Party Name and GL A/c No.');
return; return;
} }
this.scripts = [ if (f.bondType === 'FD' && !f.interestGlAccountId) {
{ this.toast('An FD bank needs its Interest GL as well.');
scriptCode: this.newScript.scriptCode || `SCR-${this.scripts.length + 1}`, return;
isin: '', }
faceValuePerUnit: 100, const tds = f.bondType === 'FD' && !!f.tdsApplicable;
...this.newScript if (tds && !(Number(f.tdsRate) > 0 && Number(f.tdsRate) < 100)) {
} as ScriptMaster, this.toast('Enter the TDS rate (%) this bank deducts on FD interest.');
...this.scripts return;
]; }
this.newScript = {}; const share = f.bondType === 'SHARE';
this.toast('Script master added.'); if (share && !f.institutionType) {
this.toast('Select the Institution Type of this share institution.');
return;
}
if (share && f.institutionType === 'OTHER_COOP_SOCIETY' && !(Number(f.subscribedCapital) > 0)) {
this.toast('Enter the subscribed share capital of the society (the 5% limit on one society).');
return;
}
const body = {
bondType: f.bondType,
partyName: f.partyName,
address1: f.address1 ?? '',
address2: f.address2 ?? '',
address3: f.address3 ?? '',
city: f.city ?? '',
pincode: f.pincode ?? '',
phone: f.phone ?? '',
fax: f.fax ?? '',
bankType: f.bankType ?? '',
glAccountId: Number(f.glAccountId),
interestGlAccountId: f.bondType === 'FD' && f.interestGlAccountId ? Number(f.interestGlAccountId) : null,
tdsApplicable: tds,
tdsRate: tds ? Number(f.tdsRate) : null,
rbiCode: f.rbiCode ?? '',
limit: f.limit ?? null,
leiNo: f.leiNo ?? '',
cpBankName: f.cpBankName ?? '',
cpBranchName: f.cpBranchName ?? '',
cpAccountNo: f.cpAccountNo ?? '',
cpIfsc: f.cpIfsc ?? '',
// Shares only: who issued them (only co-operative / membership shares may be held), and what values them.
institutionType: share ? f.institutionType : '',
subscribedCapital: share && f.subscribedCapital ? Number(f.subscribedCapital) : null,
lastBalanceSheetDate: share ? (f.lastBalanceSheetDate ?? '') : '',
inLiquidation: share && !!f.inLiquidation,
active: f.status !== 'INACTIVE',
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
const request = f.id ? this.investmentService.updateParty(f.id, body) : this.investmentService.createParty(body);
this.saving = true;
request.subscribe({
next: (res) => {
this.saving = false;
this.toast(
awaitingApproval(res)
? sentForApproval(f.id ? `Change to party ${f.partyCode}` : `New party ${f.partyName ?? ''}`)
: `Party ${res.resourceIdentifier ?? f.partyCode} ${f.id ? 'updated' : 'saved'}.`
);
this.partyForm = {};
this.reloadMasters();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
}
searchParty(): void {
const p = this.parties.find((x) => x.partyCode.toUpperCase() === this.partySearchCode.trim().toUpperCase());
if (!p) {
this.toast(`Party Code ${this.partySearchCode} not found.`);
return;
}
this.partyForm = { ...p };
this.toast(`Party ${p.partyCode} loaded.`);
}
cancelParty(): void {
this.partyForm = {};
this.partySearchCode = '';
}
dropParty(): void {
const f = this.partyForm;
if (!f.id) {
this.toast('Search a saved party first, then Drop.');
return;
}
this.investmentService.deleteParty(f.id).subscribe({
next: (res) => {
this.toast(
awaitingApproval(res) ? sentForApproval(`Drop of party ${f.partyCode}`) : `Party ${f.partyCode} dropped.`
);
this.partyForm = {};
this.reloadMasters();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
// -------------------- Script Master --------------------
/** Coupon / maturity terms only apply to debt securities. */
get scriptIsDebtSecurity(): boolean {
return [
'GOVT_SECURITY',
'NON_SLR',
'BOND'
].includes(this.scriptForm.instrumentType ?? '');
}
/** Corporate bonds, CPs and CDs carry a credit rating and a listed / unlisted flag (RBI non-SLR rules). */
get scriptIsRated(): boolean {
return [
'NON_SLR',
'BOND'
].includes(this.scriptForm.instrumentType ?? '');
}
/** Whether the rating entered meets the RBI minimum: "A" for bonds, "A2" for CP / CD. */
get scriptRatingEligible(): boolean {
const r = this.scriptForm.rating;
return !!r && ELIGIBLE_RATINGS.includes(r) && this.scriptIsShortTerm === /^A[1-4]\+?$/.test(r);
}
/** A CP / CD runs one year or less and takes a short-term rating (A1+ to A4); a bond takes a long-term one. */
get scriptIsShortTerm(): boolean {
const f = this.scriptForm;
if (!f.issueDate || !f.maturityDate) {
return false;
}
const issue = parseDDMMYYYY(f.issueDate);
const limit = new Date(issue.getFullYear() + 1, issue.getMonth(), issue.getDate());
return parseDDMMYYYY(f.maturityDate) <= limit;
}
/** Preview of the code the backend will assign — a type letter + running number, e.g. G00001. */
get nextScriptCode(): string {
const prefixByType: Record<InstrumentType, string> = {
GOVT_SECURITY: 'G',
NON_SLR: 'N',
BOND: 'B',
MUTUAL_FUND: 'M',
FD: 'F',
SHARE: 'S'
};
const type = this.scriptForm.instrumentType;
if (!type) {
return '';
}
const prefix = prefixByType[type];
const pattern = new RegExp(`^${prefix}(\\d{5})$`);
const nums = this.scripts
.map((s) => pattern.exec(s.scriptCode)?.[1])
.filter((n): n is string => !!n)
.map((n) => parseInt(n, 10));
return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(5, '0')}`;
}
get scriptPeriod(): { years: number; months: number; days: number } {
return periodBetween(this.scriptForm.issueDate, this.scriptForm.maturityDate);
}
saveScript(): void {
const f = this.scriptForm;
if (!f.instrumentType || !f.scriptName || !f.glAccountId) {
this.toast('Enter Bond Type, Script Desc and GL A/c No.');
return;
}
if (this.scriptIsDebtSecurity && (!f.isin || f.couponRate == null || !f.issueDate || !f.maturityDate)) {
this.toast('Enter ISIN, Int Rate, Issue Date and Maturity Date for this security.');
return;
}
if (this.scriptIsRated && !f.rating) {
this.toast('Enter the credit rating of this bond / CP / CD.');
return;
}
const body = {
bondType: f.instrumentType,
slrType: f.slrType ?? '',
scriptDesc: f.scriptName,
glAccountId: Number(f.glAccountId),
isin: f.isin ?? '',
intRate: f.couponRate ?? null,
// Units = face value / face value per unit (100 for G-Secs by default; bonds can be 1,000 or 10,00,000).
...(f.faceValuePerUnit ? { faceValuePerUnit: Number(f.faceValuePerUnit) } : {}),
issueDate: f.issueDate ?? '',
maturityDate: f.maturityDate ?? '',
companyName: f.companyName ?? '',
address1: f.address1 ?? '',
address2: f.address2 ?? '',
address3: f.address3 ?? '',
city: f.city ?? '',
pincode: f.pincode ?? '',
phone: f.phone ?? '',
fax: f.fax ?? '',
corpCode: f.corpCode ?? '',
rating: this.scriptIsRated ? (f.rating ?? '') : '',
ratingAgency: this.scriptIsRated ? (f.ratingAgency ?? '') : '',
listed: !this.scriptIsRated || f.listed !== false,
active: f.active !== false,
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
const request = f.id ? this.investmentService.updateScript(f.id, body) : this.investmentService.createScript(body);
this.saving = true;
request.subscribe({
next: (res) => {
this.saving = false;
this.toast(
awaitingApproval(res)
? sentForApproval(f.id ? `Change to script ${f.scriptCode}` : `New script ${f.scriptName ?? ''}`)
: `Script ${res.resourceIdentifier ?? f.scriptCode} ${f.id ? 'updated' : 'saved'}.`
);
this.scriptForm = {};
this.reloadMasters();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
}
searchScript(): void {
const s = this.scripts.find((x) => x.scriptCode.toUpperCase() === this.scriptSearchCode.trim().toUpperCase());
if (!s) {
this.toast(`Script Code ${this.scriptSearchCode} not found.`);
return;
}
this.scriptForm = { ...s };
this.toast(`Script ${s.scriptCode} loaded.`);
}
cancelScript(): void {
this.scriptForm = {};
this.scriptSearchCode = '';
} }
addSubType(): void { addSubType(): void {
@@ -248,24 +553,163 @@ export class InvestmentMastersComponent {
this.toast('SGL Bank master added.'); this.toast('SGL Bank master added.');
} }
private loadDailyRates(): void {
this.investmentService.getDailyRates().subscribe({
next: (rows) => (this.dailyScriptRates = rows.map(toDailyScriptRate)),
error: (err) => this.toast(`Could not load daily script rates: ${apiErrorMessage(err)}`)
});
}
addDailyRate(): void { addDailyRate(): void {
if (!this.newDailyRate.scriptCode || !this.newDailyRate.rateDate || this.newDailyRate.closeRate == null) { const r = this.newDailyRate;
if (!r.scriptCode || !r.rateDate || r.closeRate == null) {
this.toast('Enter Rate Date, Script Code and Close Rate.'); this.toast('Enter Rate Date, Script Code and Close Rate.');
return; return;
} }
const script = this.scripts.find((s) => s.scriptCode === this.newDailyRate.scriptCode); const script = this.scripts.find((s) => s.scriptCode === r.scriptCode);
this.dailyScriptRates = [ if (!script?.id) {
{ this.toast(`Script ${r.scriptCode} is not saved in Script Master.`);
scriptName: script?.scriptName || '', return;
openRate: this.newDailyRate.closeRate, }
highRate: this.newDailyRate.closeRate, const body = {
lowRate: this.newDailyRate.closeRate, scriptId: script.id,
...this.newDailyRate rateDate: r.rateDate,
} as DailyScriptRate, closeRate: r.closeRate,
...this.dailyScriptRates ...(r.openRate != null ? { openRate: r.openRate } : {}),
]; ...(r.highRate != null ? { highRate: r.highRate } : {}),
...(r.lowRate != null ? { lowRate: r.lowRate } : {}),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.investmentService.createDailyRate(body).subscribe({
next: () => {
this.toast(`Rate for ${r.scriptCode} on ${r.rateDate} saved.`);
this.newDailyRate = {}; this.newDailyRate = {};
this.toast('Daily script rate added.'); this.loadDailyRates();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
// -------------------- Daily rate upload (CSV / Excel) --------------------
/** Result of the last upload, one line per row of the file. */
uploadResults: Array<{ row: number; text: string; ok: boolean }> = [];
uploading = false;
/**
* Uploads the day's rates / NAVs from a CSV or Excel file (Jitendra Sir): columns Script Code (or ISIN), Rate Date
* (DD-MM-YYYY), Close, and optionally Open, High, Low; the first row is the header. Each row is saved on its own,
* so one bad row does not stop the rest.
*/
async uploadDailyRates(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
this.uploadResults = [];
let rows: string[][];
try {
rows = file.name.toLowerCase().endsWith('.xlsx') ? await this.readXlsx(file) : this.readCsv(await file.text());
} catch {
this.toast('Could not read the file. Use CSV or Excel (.xlsx).');
return;
}
const data = rows.slice(1).filter((r) => r.some((c) => c.trim() !== ''));
if (!data.length) {
this.toast('The file has no rate rows.');
return;
}
this.uploading = true;
for (let i = 0; i < data.length; i++) {
const [
code,
rateDate,
close,
open,
high,
low
] = data[i].map((c) => c.trim());
const script = this.scripts.find(
(s) =>
s.scriptCode.toUpperCase() === code.toUpperCase() || (!!s.isin && s.isin.toUpperCase() === code.toUpperCase())
);
const num = (v?: string) => (v ? Number(v.replace(/,/g, '')) : null);
if (!script?.id || !rateDate || num(close) == null || isNaN(num(close) as number)) {
this.uploadResults.push({
row: i + 2,
text: `${code || '?'}: script not found or date / close missing`,
ok: false
});
continue;
}
const body = {
scriptId: script.id,
rateDate,
closeRate: num(close),
...(num(open) != null ? { openRate: num(open) } : {}),
...(num(high) != null ? { highRate: num(high) } : {}),
...(num(low) != null ? { lowRate: num(low) } : {}),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
try {
await firstValueFrom(this.investmentService.createDailyRate(body, true));
this.uploadResults.push({ row: i + 2, text: `${script.scriptCode} ${rateDate}: saved`, ok: true });
} catch (err) {
this.uploadResults.push({
row: i + 2,
text: `${script.scriptCode} ${rateDate}: ${apiErrorMessage(err)}`,
ok: false
});
}
}
this.uploading = false;
const saved = this.uploadResults.filter((r) => r.ok).length;
this.toast(`${saved} of ${data.length} rate(s) saved.`);
this.loadDailyRates();
}
private readCsv(text: string): string[][] {
return text.split(/\r?\n/).map((line) => line.split(',').map((c) => c.replace(/^"|"$/g, '')));
}
private async readXlsx(file: File): Promise<string[][]> {
const ExcelJS = await import('exceljs');
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(await file.arrayBuffer());
const sheet = wb.worksheets[0];
const rows: string[][] = [];
sheet.eachRow({ includeEmpty: false }, (row) => {
const cells: string[] = [];
for (let c = 1; c <= 6; c++) {
const v = row.getCell(c).value;
cells.push(
v instanceof Date
? `${String(v.getDate()).padStart(2, '0')}-${String(v.getMonth() + 1).padStart(2, '0')}-${v.getFullYear()}`
: v == null
? ''
: String(v)
);
}
rows.push(cells);
});
return rows;
}
/** A wrong rate is corrected by dropping it and entering it again. */
dropDailyRate(r: DailyScriptRate): void {
if (!r.id) {
return;
}
this.investmentService.deleteDailyRate(r.id).subscribe({
next: () => {
this.toast(`Rate for ${r.scriptCode} on ${r.rateDate} dropped.`);
this.loadDailyRates();
},
error: (err) => this.toast(apiErrorMessage(err))
});
} }
// -------------------- Export to Excel -------------------- // -------------------- Export to Excel --------------------
@@ -279,46 +723,121 @@ export class InvestmentMastersComponent {
return one( return one(
[ [
'Party Code', 'Party Code',
'Bond Type',
'Party Name', 'Party Name',
'Type', 'Address',
'PAN No' 'City',
'Pincode',
'Phone',
'Fax',
'Bank Type',
'GL A/c No',
'RBI Code',
'Limit',
'LEI No',
'Counterparty Bank',
'Branch',
'Account No',
'IFSC',
'Status'
], ],
[ [
12, 12,
14,
30, 30,
16, 36,
14 14,
10,
14,
14,
24,
12,
12,
14,
22,
24,
20,
18,
14,
10
], ],
this.parties.map((p) => [ this.parties.map((p) => [
p.partyCode, p.partyCode,
p.bondType ?? '',
p.partyName, p.partyName,
p.partyType, [
p.panNo p.address1,
p.address2,
p.address3
]
.filter((x) => !!x)
.join(', '),
p.city ?? '',
p.pincode ?? '',
p.phone ?? '',
p.fax ?? '',
p.bankType ?? '',
p.glCode ?? '',
p.rbiCode ?? '',
p.limit ?? null,
p.leiNo ?? '',
p.cpBankName ?? '',
p.cpBranchName ?? '',
p.cpAccountNo ?? '',
p.cpIfsc ?? '',
p.status ?? 'ACTIVE'
]) ])
); );
case 'script': case 'script':
return one( return one(
[ [
'Script Code', 'Script Code',
'Script Name', 'Bond Type',
'Instrument', 'SLR',
'Script Desc',
'GL A/c No',
'ISIN', 'ISIN',
'Face Value/Unit' 'Int Rate %',
'Issue Date',
'Maturity Date',
'Period (Y/M/D)',
'Face Value/Unit',
'Company Name',
'Corp Code'
], ],
[ [
14,
16, 16,
12,
30, 30,
12,
16, 16,
16, 10,
16 14,
14,
14,
14,
24,
12
], ],
this.scripts.map((s) => [ this.scripts.map((s) => {
const p = periodBetween(s.issueDate, s.maturityDate);
return [
s.scriptCode, s.scriptCode,
s.scriptName,
s.instrumentType, s.instrumentType,
s.slrType ?? '',
s.scriptName,
s.glCode ?? '',
s.isin, s.isin,
s.faceValuePerUnit s.couponRate ?? null,
]) s.issueDate ?? '',
s.maturityDate ?? '',
s.issueDate && s.maturityDate ? `${p.years}/${p.months}/${p.days}` : '',
s.faceValuePerUnit,
s.companyName ?? '',
s.corpCode ?? ''
];
})
); );
case 'subtype': case 'subtype':
return one( return one(
@@ -12,17 +12,26 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div> <div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
<h1 class="pg-title">Investment Reports</h1> <h1 class="pg-title">Investment Reports</h1>
<p class="pg-sub"> <p class="pg-sub">
Balance reports, ledgers, registers, maturity lists and the consolidated portfolio — all derived live from the Balance reports, ledgers, registers, maturity lists and the consolidated portfolio from the saved deals, FDs and
same holdings/transactions used by the Entry screens. their GL entries, as on {{ asOnDate }}.
</p> </p>
</div> </div>
<div class="pg-toolbar"> <div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()"> <button type="button" class="pg-btn pg-btn--ghost" [disabled]="loading" (click)="load()">
{{ loading ? 'Loading…' : 'Refresh' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating || loading" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }} {{ generating ? 'Exporting…' : 'Export to Excel' }}
</button> </button>
</div> </div>
</header> </header>
@if (error) {
<div class="pg-card">
<p class="inv-hint inv-hint--bad">{{ error }}</p>
</div>
}
<div class="cctr-tabs"> <div class="cctr-tabs">
<button <button
type="button" type="button"
@@ -113,7 +122,8 @@
<th>Script / Party</th> <th>Script / Party</th>
<th class="num">Units</th> <th class="num">Units</th>
<th class="num">Face Value</th> <th class="num">Face Value</th>
<th class="num">Market/Purchase Rate</th> <th class="num">Book Value</th>
<th class="num">Rate</th>
<th>Status</th> <th>Status</th>
</tr> </tr>
</thead> </thead>
@@ -124,7 +134,7 @@
: activeReport === 'shareBalance' : activeReport === 'shareBalance'
? shareHoldings ? shareHoldings
: fdHoldings; : fdHoldings;
track h.certNo track h.instrumentType + ':' + h.id
) { ) {
<tr> <tr>
<td> <td>
@@ -133,11 +143,24 @@
<td>{{ h.scriptName }} — {{ h.party }}</td> <td>{{ h.scriptName }} — {{ h.party }}</td>
<td class="num">{{ h.units | number }}</td> <td class="num">{{ h.units | number }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td> <td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ marketPrice(h.scriptCode) || h.purchaseRate }}</td> <td class="num">₹{{ h.bookValue ?? h.faceValue | number: '1.2-2' }}</td>
<td class="num">
@if (marketPrice(h.scriptCode); as mp) {
{{ mp }} <small>(market {{ marketRateDate(h.scriptCode) }})</small>
} @else {
{{ h.purchaseRate }} <small>(purchase)</small>
}
</td>
<td> <td>
<span [class]="statusClass(h)">{{ h.status }}</span> <span [class]="statusClass(h)">{{ h.status }}</span>
</td> </td>
</tr> </tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">
{{ activeReport === 'shareBalance' ? 'No share holdings.' : 'No holdings.' }}
</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -145,7 +168,7 @@
} }
@if (activeReport === 'bankInvList') { @if (activeReport === 'bankInvList') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Bank Investment List</div> <div class="pos-section-label">Bank Investment List — FDs held with each bank</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
@@ -280,7 +303,7 @@
: activeReport === 'amortizeLedger' : activeReport === 'amortizeLedger'
? amortizeTxns ? amortizeTxns
: dealSlips; : dealSlips;
track t.voucherNo track $index
) { ) {
<tr> <tr>
<td>{{ t.txnDate }}</td> <td>{{ t.txnDate }}</td>
@@ -303,7 +326,10 @@
} }
@if (activeReport === 'intDueRegister') { @if (activeReport === 'intDueRegister') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Interest Due Register (Non-Cumulative Holdings)</div> <div class="pos-section-label">
Interest Due Register — interest earned up to {{ asOnDate }} and not received yet ·
<a routerLink="/investment/diary">Due Date Diary</a>
</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
@@ -311,11 +337,11 @@
<th>Script / Party</th> <th>Script / Party</th>
<th>Frequency</th> <th>Frequency</th>
<th class="num">Rate</th> <th class="num">Rate</th>
<th class="num col-in">Due Amount (as on {{ asOnDate }})</th> <th class="num col-in">Receivable (as on {{ asOnDate }})</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of interestDueRegister; track h.certNo) { @for (h of interestDueRegister; track h.instrumentType + ':' + h.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ h.certNo }}</span>
@@ -325,6 +351,10 @@
<td class="num">{{ h.interestRate }}%</td> <td class="num">{{ h.interestRate }}%</td>
<td class="num col-in">₹{{ h.dueAmount | number: '1.2-2' }}</td> <td class="num col-in">₹{{ h.dueAmount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No interest receivable.</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -344,7 +374,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of slrBondRegister; track h.certNo) { @for (h of slrBondRegister; track h.instrumentType + ':' + h.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ h.certNo }}</span>
@@ -361,40 +391,56 @@
} }
@if (activeReport === 'pendingReceived') { @if (activeReport === 'pendingReceived') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Received Interest / Dividend</div> <div class="pos-section-label">
Interest Received — {{ receivedFrom }} to {{ asOnDate }} (coupons, FD payouts and FD interest paid at closure)
</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Investment</th>
<th>Cert No</th> <th>Deal No</th>
<th>Particulars</th> <th>Particulars</th>
<th class="num col-in">Amount</th> <th class="num col-in">Received</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (t of receivedInterestTxns; track t.voucherNo) { @for (x of receivedRows; track x.instrument + ':' + x.refId) {
<tr> <tr>
<td>{{ t.txnDate }}</td> <td>{{ x.instrument === 'FD' ? 'FD' : 'Govt Security / Bond' }}</td>
<td>{{ t.certNo }}</td> <td>
<td>{{ t.particulars }}</td> <span class="pg-chip">{{ x.dealNo }}</span>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td> </td>
<td>{{ x.particulars }}</td>
<td class="num col-in">₹{{ x.received | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="pg-empty">No interest received in this period.</td>
</tr> </tr>
} }
</tbody> </tbody>
@if (receivedRows.length) {
<tfoot>
<tr class="cctr-total-row">
<td colspan="3">Total</td>
<td class="num">₹{{ receivedTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
}
</table> </table>
</div> </div>
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Pending Interest (No Interest Posted Yet)</div> <div class="pos-section-label">Pending Interest — earned up to {{ asOnDate }}, not received yet</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Cert No</th> <th>Cert No</th>
<th>Script / Party</th> <th>Script / Party</th>
<th class="num col-out">Accrued (Pending)</th> <th class="num col-out">Receivable</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of pendingInterestHoldings; track h.certNo) { @for (h of pendingInterestHoldings; track h.instrumentType + ':' + h.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ h.certNo }}</span>
@@ -487,11 +533,13 @@
<th>Cert No</th> <th>Cert No</th>
<th>Party</th> <th>Party</th>
<th class="num">Face Value</th> <th class="num">Face Value</th>
<th>Matured On</th> <th>Maturity Date</th>
<th>Closed As</th>
<th>Closed On</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of maturedFdList; track h.certNo) { @for (h of maturedFdList; track h.instrumentType + ':' + h.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ h.certNo }}</span>
@@ -499,10 +547,12 @@
<td>{{ h.party }}</td> <td>{{ h.party }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td> <td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td>{{ h.maturityDate }}</td> <td>{{ h.maturityDate }}</td>
<td>{{ h.closureType || '—' }}</td>
<td>{{ h.closedOnDate || '—' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="4" class="pg-empty">No matured FDs.</td> <td colspan="6" class="pg-empty">No matured FDs.</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -524,7 +574,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of maturingList; track h.certNo) { @for (h of maturingList; track h.instrumentType + ':' + h.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ h.certNo }}</span>
@@ -543,24 +593,12 @@
@if (activeReport === 'maturityPattern') { @if (activeReport === 'maturityPattern') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Maturity Pattern Register</div> <div class="pos-section-label">Maturity Pattern Register</div>
<table class="pg-table"> <p class="pg-sub" style="padding: 0 16px">
<thead> The maturity pattern (up to 1 year, 1–3, 3–5 and over 5 years) is the Bucket Wise Investment report.
<tr> </p>
<th>Bucket</th> <div class="inv-form-actions" style="padding: 8px 16px">
<th class="num">Holdings</th> <a class="pg-btn pg-btn--primary" routerLink="/investment/bucket-report">Open Bucket Wise Investment</a>
<th class="num">Face Value</th> </div>
</tr>
</thead>
<tbody>
@for (b of maturityBuckets; track b.label) {
<tr>
<td>{{ b.label }}</td>
<td class="num">{{ b.count }}</td>
<td class="num">₹{{ b.faceValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div> </div>
} }
@if ( @if (
@@ -601,7 +639,7 @@
: activeReport === 'shareSecList' : activeReport === 'shareSecList'
? shareSecList ? shareSecList
: mutualFundList; : mutualFundList;
track h.certNo track h.instrumentType + ':' + h.id
) { ) {
<tr> <tr>
<td> <td>
@@ -615,6 +653,12 @@
<span [class]="statusClass(h)">{{ h.status }}</span> <span [class]="statusClass(h)">{{ h.status }}</span>
</td> </td>
</tr> </tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">
{{ activeReport === 'shareSecList' ? 'No share holdings.' : 'No holdings.' }}
</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -622,35 +666,35 @@
} }
@if (activeReport === 'shiftingReport') { @if (activeReport === 'shiftingReport') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label"> <div class="pos-section-label">Shifting Report — securities transferred to another script / category</div>
Shifting Report — SLR Securities with Market Deviation ≥ 1% (AFS/HTM review candidates)
</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Cert No</th> <th>Date</th>
<th>Script</th> <th>Deal No</th>
<th class="num">Purchase Rate</th> <th>From</th>
<th class="num">Market Price</th> <th>To</th>
<th class="num">Deviation %</th> <th class="num">Units</th>
<th class="num">Book Value</th>
<th>Voucher</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of shiftingCandidates; track h.certNo) { @for (x of transfers; track x.id) {
<tr> <tr>
<td>{{ x.transferDate }}</td>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ x.dealNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td class="num">{{ h.purchaseRate }}</td>
<td class="num">{{ h.marketPrice }}</td>
<td class="num" [class.col-in]="h.deviationPct > 0" [class.col-out]="h.deviationPct < 0">
{{ h.deviationPct }}%
</td> </td>
<td>{{ x.fromScriptCode }} {{ x.fromScriptDesc }}</td>
<td>{{ x.toScriptCode }} {{ x.toScriptDesc }}</td>
<td class="num">{{ x.units | number: '1.0-4' }}</td>
<td class="num">₹{{ x.bookValue | number: '1.2-2' }}</td>
<td>{{ x.voucherNo || '—' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="5" class="pg-empty">No securities crossing the 1% deviation threshold today.</td> <td colspan="7" class="pg-empty">No securities shifted.</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -687,7 +731,7 @@
<tfoot> <tfoot>
<tr class="cctr-total-row"> <tr class="cctr-total-row">
<td>Total</td> <td>Total</td>
<td class="num"></td> <td class="num">{{ portfolioTotalCount }}</td>
<td class="num">₹{{ portfolioTotalFaceValue | number: '1.2-2' }}</td> <td class="num">₹{{ portfolioTotalFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ portfolioTotalAccrued | number: '1.2-2' }}</td> <td class="num">₹{{ portfolioTotalAccrued | number: '1.2-2' }}</td>
<td class="num">₹{{ portfolioTotalMaturity | number: '1.2-2' }}</td> <td class="num">₹{{ portfolioTotalMaturity | number: '1.2-2' }}</td>
@@ -6,35 +6,46 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { import {
ALL_HOLDINGS,
ALL_TXNS,
FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
MUTUAL_FUND_HOLDINGS,
SHARE_HOLDINGS,
SCRIPT_MARKET_DETAIL,
InstrumentType, InstrumentType,
InvestmentHolding, InvestmentHolding,
InvestmentTxn, InvestmentTxn,
INSTRUMENT_LABELS, INSTRUMENT_LABELS,
accruedInterest,
maturityAmount,
daysToMaturity, daysToMaturity,
parseDDMMYYYY,
todayDDMMYYYY todayDDMMYYYY
} from '../investment-data'; } from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export'; import { exportWorkbook, ExportSheetSpec } from '../investment-export';
import {
InvestmentDailyRateDto,
InvestmentFdDto,
InvestmentFdScheduleDto,
InvestmentInterestReportRowDto,
InvestmentSecurityTransferDto,
InvestmentService,
apiErrorMessage,
bookValueSoldByPurchase,
toFdHolding,
toFdTxns,
toHolding,
toInterestTxn,
toPurchaseTxn,
toSaleTxn,
toTransferTxn
} from '../investment.service';
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio'; type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
/** /**
* ~20 legacy Investment reports, grouped into 4 tabs. Every figure here is derived from the * ~20 legacy Investment reports in 4 tabs, on live data: the saved purchases and FDs as holdings, and their GL
* same ALL_HOLDINGS/ALL_TXNS arrays used by the Entry screens and Dashboard — so a report can * entries (settlements, interest, sales, redemptions, amortisation, transfers, FD placements and closures) as the
* never drift out of sync with what a Purchase/Sell/Interest entry actually recorded. * ledgers. Interest receivable comes from the Interest Received & Receivable report, market rates from the Daily
* Script Master.
*/ */
@Component({ @Component({
selector: 'mifosx-investment-reports', selector: 'mifosx-investment-reports',
@@ -44,37 +55,181 @@ type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
'../investment-shared.scss' '../investment-shared.scss'
], ],
standalone: true, standalone: true,
imports: [CommonModule] imports: [
CommonModule,
RouterLink
]
}) })
export class InvestmentReportsComponent { export class InvestmentReportsComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
activeGroup: ReportGroup = 'balance'; activeGroup: ReportGroup = 'balance';
activeReport = 'govtSecBalance'; activeReport = 'govtSecBalance';
asOnDate = todayDDMMYYYY(); asOnDate = todayDDMMYYYY();
loading = false;
error = '';
generating = false; generating = false;
toastMsg = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
/** Saved purchases and FDs as holdings, and their GL entries, newest first. */
holdings: InvestmentHolding[] = [];
txns: InvestmentTxn[] = [];
/** Interest received in the financial year to the as-on date, per deal / FD (Interest Received & Receivable report). */
receivedRows: InvestmentInterestReportRowDto[] = [];
receivedFrom = '';
/** Every purchase deal, pending ones included — the Deal Slip register (the ledgers hold only what reached the GL). */
dealSlipTxns: InvestmentTxn[] = [];
/** GOI category transfers (security shifted to another script / category). */
transfers: InvestmentSecurityTransferDto[] = [];
/** FD interest lines need one call per FD, so they load when a report needs them. */
fdInterestLoaded = false;
private fds: InvestmentFdDto[] = [];
private receivable = new Map<string, number>();
private fdMaturity = new Map<number, number>();
private latestRate = new Map<string, number>();
private latestRateDate = new Map<string, string>();
ngOnInit(): void {
this.load();
}
load(): void {
const d = parseDDMMYYYY(this.asOnDate);
const fyStart = `01-04-${d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1}`;
this.loading = true;
this.error = '';
this.fdInterestLoaded = false;
forkJoin({
purchases: this.investmentService.getPurchases(),
fds: this.investmentService.getFds(),
sales: this.investmentService.getSales(),
postings: this.investmentService.getInterestPostings(),
transfers: this.investmentService.getTransfers(),
interest: this.investmentService.getInterestReport(fyStart, this.asOnDate),
rates: this.investmentService.getDailyRates().pipe(catchError(() => of([] as InvestmentDailyRateDto[])))
}).subscribe({
next: (r) => {
this.fds = r.fds;
this.transfers = r.transfers;
// A holding is shown at the book value still held: cost less what settled sales and write-offs took off.
const sold = bookValueSoldByPurchase(r.sales);
this.holdings = [
...r.purchases.map((p) => ({ ...toHolding(p), bookValue: p.bookValue - (sold.get(p.id) ?? 0) })),
...r.fds.map(toFdHolding)
];
// Ledgers show what reached the GL: settled purchases and sales only.
this.txns = this.byDateDesc([
...r.purchases.filter((p) => !!p.glTransactionId).map(toPurchaseTxn),
...r.postings.map(toInterestTxn),
...r.sales.filter((x) => x.status === 'SETTLED').map(toSaleTxn),
...r.transfers.map(toTransferTxn),
...r.fds.flatMap((f) => toFdTxns(f, []))
]);
this.dealSlipTxns = this.byDateDesc(r.purchases.map(toPurchaseTxn));
// Received = coupons, FD payouts and FD interest paid at closure, as the interest report counts them.
this.receivedRows = r.interest.rows.filter((x) => x.received > 0);
this.receivedFrom = r.interest.fromDate;
this.receivable = new Map(
r.interest.rows.map((x) => [
x.instrument + ':' + x.refId,
x.receivable
])
);
this.fdMaturity = new Map(
r.fds
.filter((f) => f.id != null)
.map((f) => [
f.id as number,
f.expectedMaturityAmount ?? f.principal ?? 0
])
);
// Latest close on or before the as-on date, per script.
const asOn = parseDDMMYYYY(this.asOnDate).getTime();
this.latestRate = new Map();
[...r.rates]
.filter((x) => parseDDMMYYYY(x.rateDate).getTime() <= asOn)
.sort((a, b) => parseDDMMYYYY(a.rateDate).getTime() - parseDDMMYYYY(b.rateDate).getTime())
.forEach((x) => {
this.latestRate.set(x.scriptCode, x.closeRate);
this.latestRateDate.set(x.scriptCode, x.rateDate);
});
this.loading = false;
if (this.activeReport === 'fdLedger') {
this.loadFdInterest();
}
},
error: (err: unknown) => {
this.loading = false;
this.error = apiErrorMessage(err);
}
});
}
/** Adds each FD's posted interest lines to the ledgers (one schedule call per FD). */
private loadFdInterest(): void {
if (this.fdInterestLoaded || !this.fds.length) {
return;
}
this.fdInterestLoaded = true;
const withId = this.fds.filter((f) => f.id != null);
forkJoin(
withId.map((f) =>
this.investmentService
.getFdSchedule(f.id as number)
.pipe(catchError(() => of(null as InvestmentFdScheduleDto | null)))
)
).subscribe((schedules) => {
const lines: InvestmentTxn[] = [];
schedules.forEach((s, i) => {
if (s) {
lines.push(...toFdTxns(withId[i], s.rows).filter((t) => t.txnType === 'INTEREST'));
}
});
this.txns = this.byDateDesc([
...this.txns,
...lines
]);
});
}
private byDateDesc(txns: InvestmentTxn[]): InvestmentTxn[] {
return txns
.filter((t) => !!t.txnDate)
.sort((a, b) => parseDDMMYYYY(b.txnDate).getTime() - parseDDMMYYYY(a.txnDate).getTime());
}
setGroup(group: ReportGroup, defaultReport: string): void { setGroup(group: ReportGroup, defaultReport: string): void {
this.activeGroup = group; this.activeGroup = group;
this.activeReport = defaultReport; this.setReport(defaultReport);
} }
setReport(report: string): void { setReport(report: string): void {
this.activeReport = report; this.activeReport = report;
if (report === 'fdLedger') {
this.loadFdInterest();
}
} }
instrumentLabel(type: InstrumentType): string { instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type]; return INSTRUMENT_LABELS[type];
} }
/** Interest earned up to the as-on date and not yet received (Interest Received & Receivable report). */
accrued(h: InvestmentHolding): number { accrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate); return this.receivable.get((h.instrumentType === 'FD' ? 'FD' : 'SECURITY') + ':' + h.id) ?? 0;
} }
/** What comes back at maturity: the FD maturity amount, a security's face value, a fund's cost. */
maturity(h: InvestmentHolding): number { maturity(h: InvestmentHolding): number {
return maturityAmount(h); if (h.instrumentType === 'FD') {
return this.fdMaturity.get(h.id ?? -1) ?? h.faceValue;
}
return h.instrumentType === 'MUTUAL_FUND' || h.instrumentType === 'SHARE'
? (h.bookValue ?? h.faceValue)
: h.faceValue;
} }
daysLeft(h: InvestmentHolding): number { daysLeft(h: InvestmentHolding): number {
@@ -85,23 +240,38 @@ export class InvestmentReportsComponent {
return `inv-badge inv-badge--${h.status.toLowerCase()}`; return `inv-badge inv-badge--${h.status.toLowerCase()}`;
} }
/** Latest close in the Daily Script Master on or before the as-on date. */
marketPrice(scriptCode: string): number | null { marketPrice(scriptCode: string): number | null {
return SCRIPT_MARKET_DETAIL.find((m) => m.scriptCode === scriptCode)?.marketPrice ?? null; return this.latestRate.get(scriptCode) ?? null;
}
/** Date of that latest close. */
marketRateDate(scriptCode: string): string {
return this.latestRateDate.get(scriptCode) ?? '';
}
private active(type?: InstrumentType): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && (!type || h.instrumentType === type));
}
private ofType(type: InstrumentType): InvestmentHolding[] {
return this.holdings.filter((h) => h.instrumentType === type);
} }
// -------------------- Balance Reports -------------------- // -------------------- Balance Reports --------------------
get govtSecHoldings(): InvestmentHolding[] { get govtSecHoldings(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS; return this.active('GOVT_SECURITY');
} }
get shareHoldings(): InvestmentHolding[] { get shareHoldings(): InvestmentHolding[] {
return SHARE_HOLDINGS; return this.active('SHARE');
} }
get fdHoldings(): InvestmentHolding[] { get fdHoldings(): InvestmentHolding[] {
return FD_HOLDINGS; return this.active('FD');
} }
/** FDs held with each bank (principal) — securities bought from a dealer are not 'with' that bank. */
get bankInvestmentList(): Array<{ party: string; count: number; totalFaceValue: number }> { get bankInvestmentList(): Array<{ party: string; count: number; totalFaceValue: number }> {
const map = new Map<string, { party: string; count: number; totalFaceValue: number }>(); const map = new Map<string, { party: string; count: number; totalFaceValue: number }>();
for (const h of ALL_HOLDINGS) { for (const h of this.active('FD')) {
const row = map.get(h.party) || { party: h.party, count: 0, totalFaceValue: 0 }; const row = map.get(h.party) || { party: h.party, count: 0, totalFaceValue: 0 };
row.count += 1; row.count += 1;
row.totalFaceValue += h.faceValue; row.totalFaceValue += h.faceValue;
@@ -112,112 +282,68 @@ export class InvestmentReportsComponent {
// -------------------- Ledgers & Registers -------------------- // -------------------- Ledgers & Registers --------------------
get fdTxns(): InvestmentTxn[] { get fdTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.instrumentType === 'FD'); return this.txns.filter((t) => t.instrumentType === 'FD');
} }
get bondTxns(): InvestmentTxn[] { get bondTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => ([ return this.txns.filter((t) => ([
'GOVT_SECURITY', 'GOVT_SECURITY',
'NON_SLR', 'NON_SLR',
'BOND' 'BOND',
'MUTUAL_FUND'
] as InstrumentType[]).includes(t.instrumentType)); ] as InstrumentType[]).includes(t.instrumentType));
} }
get shareTxns(): InvestmentTxn[] { get shareTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.instrumentType === 'SHARE'); return this.txns.filter((t) => t.instrumentType === 'SHARE');
} }
get amortizeTxns(): InvestmentTxn[] { get amortizeTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'AMORTIZE'); return this.txns.filter((t) => t.txnType === 'AMORTIZE');
} }
get dealSlips(): InvestmentTxn[] { get dealSlips(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'PURCHASE'); return this.dealSlipTxns;
} }
get slrBondRegister(): InvestmentHolding[] { get slrBondRegister(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS.filter((h) => h.subType === 'SLR'); return this.active('GOVT_SECURITY');
} }
/** Holdings with interest earned up to the as-on date and not received yet, with the amount. */
get interestDueRegister(): Array<InvestmentHolding & { dueAmount: number }> { get interestDueRegister(): Array<InvestmentHolding & { dueAmount: number }> {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE').map((h) => ({ return this.active()
...h, .map((h) => ({ ...h, dueAmount: this.accrued(h) }))
dueAmount: this.accrued(h) .filter((h) => h.dueAmount > 0);
}));
} }
get receivedInterestTxns(): InvestmentTxn[] { get receivedTotal(): number {
return ALL_TXNS.filter((t) => t.txnType === 'INTEREST' || t.txnType === 'DIVIDEND'); return this.receivedRows.reduce((t, x) => t + x.received, 0);
} }
get pendingInterestHoldings(): InvestmentHolding[] { get pendingInterestHoldings(): InvestmentHolding[] {
const receivedCerts = new Set(this.receivedInterestTxns.map((t) => t.certNo)); return this.active().filter((h) => this.accrued(h) > 0);
return ALL_HOLDINGS.filter(
(h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE' && !receivedCerts.has(h.certNo)
);
} }
// -------------------- Maturity & Lists -------------------- // -------------------- Maturity & Lists --------------------
get maturedFdList(): InvestmentHolding[] { get maturedFdList(): InvestmentHolding[] {
return FD_HOLDINGS.filter((h) => h.status === 'MATURED'); return this.ofType('FD').filter((h) => h.status === 'MATURED');
} }
get maturingList(): InvestmentHolding[] { get maturingList(): InvestmentHolding[] {
return [ return [
...FD_HOLDINGS, ...this.active('FD'),
...GOVT_SECURITY_HOLDINGS ...this.active('GOVT_SECURITY')
] ].sort((a, b) => this.daysLeft(a) - this.daysLeft(b));
.filter((h) => h.status === 'ACTIVE')
.sort((a, b) => this.daysLeft(a) - this.daysLeft(b));
}
get maturityBuckets(): Array<{ label: string; count: number; faceValue: number }> {
const buckets = [
{ label: '0-1 Year', min: -99999, max: 365, count: 0, faceValue: 0 },
{ label: '1-3 Years', min: 366, max: 1095, count: 0, faceValue: 0 },
{ label: '3-5 Years', min: 1096, max: 1825, count: 0, faceValue: 0 },
{ label: 'Above 5 Years', min: 1826, max: 999999, count: 0, faceValue: 0 }
];
for (const h of ALL_HOLDINGS.filter((x) => x.status === 'ACTIVE')) {
const days = this.daysLeft(h);
const bucket = buckets.find((b) => days >= b.min && days <= b.max);
if (bucket) {
bucket.count += 1;
bucket.faceValue += h.faceValue;
}
}
return buckets;
} }
get govtSecList(): InvestmentHolding[] { get govtSecList(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS; return this.ofType('GOVT_SECURITY');
} }
get fdSecList(): InvestmentHolding[] { get fdSecList(): InvestmentHolding[] {
return FD_HOLDINGS; return this.ofType('FD');
} }
get shareSecList(): InvestmentHolding[] { get shareSecList(): InvestmentHolding[] {
return SHARE_HOLDINGS; return this.ofType('SHARE');
} }
get mutualFundList(): InvestmentHolding[] { get mutualFundList(): InvestmentHolding[] {
return MUTUAL_FUND_HOLDINGS; return this.ofType('MUTUAL_FUND');
}
get nonSlrList(): InvestmentHolding[] {
return NON_SLR_HOLDINGS;
}
get bondList(): InvestmentHolding[] {
return BOND_HOLDINGS;
}
/** Shifting Report — SLR securities whose market value has moved away from book value by
* more than 1%, a proxy for "category shift" candidates (AFS/HTM reclassification review). */
get shiftingCandidates(): Array<InvestmentHolding & { marketPrice: number; deviationPct: number }> {
return GOVT_SECURITY_HOLDINGS.map((h) => {
const mp = this.marketPrice(h.scriptCode);
if (mp === null) {
return null;
}
const deviationPct = +(((mp - h.purchaseRate) / h.purchaseRate) * 100).toFixed(2);
return { ...h, marketPrice: mp, deviationPct };
})
.filter(
(x): x is InvestmentHolding & { marketPrice: number; deviationPct: number } =>
x !== null && Math.abs(x.deviationPct) >= 1
)
.sort((a, b) => Math.abs(b.deviationPct) - Math.abs(a.deviationPct));
} }
// -------------------- Portfolio -------------------- // -------------------- Portfolio --------------------
private readonly portfolioTypes: InstrumentType[] = [ private readonly portfolioTypes: InstrumentType[] = [
'FD',
'GOVT_SECURITY', 'GOVT_SECURITY',
'FD',
'NON_SLR', 'NON_SLR',
'BOND', 'BOND',
'MUTUAL_FUND', 'MUTUAL_FUND',
@@ -231,7 +357,7 @@ export class InvestmentReportsComponent {
maturityValue: number; maturityValue: number;
}> { }> {
return this.portfolioTypes.map((type) => { return this.portfolioTypes.map((type) => {
const holdings = ALL_HOLDINGS.filter((h) => h.instrumentType === type); const holdings = this.active(type);
return { return {
label: INSTRUMENT_LABELS[type], label: INSTRUMENT_LABELS[type],
count: holdings.length, count: holdings.length,
@@ -241,6 +367,9 @@ export class InvestmentReportsComponent {
}; };
}); });
} }
get portfolioTotalCount(): number {
return this.portfolioSummary.reduce((s, r) => s + r.count, 0);
}
get portfolioTotalFaceValue(): number { get portfolioTotalFaceValue(): number {
return this.portfolioSummary.reduce((s, r) => s + r.faceValue, 0); return this.portfolioSummary.reduce((s, r) => s + r.faceValue, 0);
} }
@@ -290,7 +419,7 @@ export class InvestmentReportsComponent {
h.purchaseRate, h.purchaseRate,
h.interestRate, h.interestRate,
h.purchaseDate, h.purchaseDate,
h.maturityDate, h.instrumentType === 'MUTUAL_FUND' ? '' : h.maturityDate,
this.accrued(h), this.accrued(h),
this.maturity(h), this.maturity(h),
h.status h.status
@@ -423,7 +552,7 @@ export class InvestmentReportsComponent {
case 'intDueRegister': case 'intDueRegister':
return one( return one(
'Interest Due Register', 'Interest Due Register',
'Interest Due Register (Non-Cumulative Holdings)', 'Interest Due Register (interest earned, not yet received)',
[ [
...this.holdingsExportHeaders, ...this.holdingsExportHeaders,
'Due Amount' 'Due Amount'
@@ -449,11 +578,34 @@ export class InvestmentReportsComponent {
return [ return [
{ {
sheetName: 'Received Interest', sheetName: 'Received Interest',
title: 'Received Interest / Dividend', title: 'Interest Received',
subtitle, subtitle: `${this.receivedFrom} to ${this.asOnDate}`,
headers: this.txnExportHeaders, headers: [
widths: this.txnExportWidths, 'Investment',
rows: this.receivedInterestTxns.map((t) => this.txnExportRow(t)) 'Deal No',
'Particulars',
'Received'
],
widths: [
14,
16,
44,
16
],
rows: [
...this.receivedRows.map((x) => [
x.instrument === 'FD' ? 'FD' : 'Govt Security / Bond',
x.dealNo,
x.particulars,
x.received
]),
[
'Total',
'',
'',
this.receivedTotal
]
]
}, },
{ {
sheetName: 'Pending Interest', sheetName: 'Pending Interest',
@@ -480,26 +632,6 @@ export class InvestmentReportsComponent {
this.holdingsExportWidths, this.holdingsExportWidths,
this.maturingList.map((h) => this.holdingsExportRow(h)) this.maturingList.map((h) => this.holdingsExportRow(h))
); );
case 'maturityPattern':
return one(
'Maturity Pattern',
'Maturity Pattern Register',
[
'Bucket',
'Holdings',
'Face Value'
],
[
22,
14,
18
],
this.maturityBuckets.map((b) => [
b.label,
b.count,
b.faceValue
])
);
case 'govtSecList': case 'govtSecList':
return one( return one(
'Govt Security List', 'Govt Security List',
@@ -535,27 +667,33 @@ export class InvestmentReportsComponent {
case 'shiftingReport': case 'shiftingReport':
return one( return one(
'Shifting Report', 'Shifting Report',
'Shifting Report — SLR Market Deviation ≥ 1%', 'Shifting Report — securities transferred to another script / category',
[ [
'Cert No', 'Date',
'Script', 'Deal No',
'Purchase Rate', 'From',
'Market Price', 'To',
'Deviation %' 'Units',
'Book Value',
'Voucher No'
], ],
[ [
14, 12,
30,
16, 16,
34,
34,
12,
16, 16,
14 20
], ],
this.shiftingCandidates.map((x) => [ this.transfers.map((x) => [
x.certNo, x.transferDate,
x.scriptName, x.dealNo,
x.purchaseRate, `${x.fromScriptCode} ${x.fromScriptDesc}`,
x.marketPrice, `${x.toScriptCode} ${x.toScriptDesc}`,
x.deviationPct x.units,
x.bookValue,
x.voucherNo ?? ''
]) ])
); );
case 'portfolio': case 'portfolio':
@@ -17,6 +17,9 @@ import { ShareInvestmentComponent } from './share-investment/share-investment.co
import { InvestmentMastersComponent } from './investment-masters/investment-masters.component'; import { InvestmentMastersComponent } from './investment-masters/investment-masters.component';
import { InvestmentReportsComponent } from './investment-reports/investment-reports.component'; import { InvestmentReportsComponent } from './investment-reports/investment-reports.component';
import { InvestmentInterestComponent } from './investment-interest/investment-interest.component'; import { InvestmentInterestComponent } from './investment-interest/investment-interest.component';
import { InvestmentDiaryComponent } from './investment-diary/investment-diary.component';
import { InvestmentInterestReportComponent } from './investment-interest-report/investment-interest-report.component';
import { InvestmentBucketReportComponent } from './investment-bucket-report/investment-bucket-report.component';
const routes: Routes = [ const routes: Routes = [
Route.withShell([ Route.withShell([
@@ -54,6 +57,21 @@ const routes: Routes = [
component: InvestmentInterestComponent, component: InvestmentInterestComponent,
data: { title: 'Investment Interest', breadcrumb: 'Investment Interest' } data: { title: 'Investment Interest', breadcrumb: 'Investment Interest' }
}, },
{
path: 'diary',
component: InvestmentDiaryComponent,
data: { title: 'Due Date Diary', breadcrumb: 'Due Date Diary' }
},
{
path: 'interest-report',
component: InvestmentInterestReportComponent,
data: { title: 'Interest Received & Receivable', breadcrumb: 'Interest Received & Receivable' }
},
{
path: 'bucket-report',
component: InvestmentBucketReportComponent,
data: { title: 'Bucket Wise Investment', breadcrumb: 'Bucket Wise Investment' }
},
{ {
path: 'reports', path: 'reports',
component: InvestmentReportsComponent, component: InvestmentReportsComponent,
+11
View File
@@ -260,3 +260,14 @@
transform: translateX(-50%) translateY(0); transform: translateX(-50%) translateY(0);
} }
} }
.inv-hint {
margin: 8px 0;
padding: 0 16px;
font-size: 13px;
color: #15803d;
&--bad {
color: #b91c1c;
}
}
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,8 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT · SHARE</div> <div class="pg-eyebrow">TREASURY · INVESTMENT · SHARE</div>
<h1 class="pg-title">Share Entry</h1> <h1 class="pg-title">Share Entry</h1>
<p class="pg-sub"> <p class="pg-sub">
Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries. Shares of the DCCB / State Co-op Bank, co-operative societies in the area of operation, and membership shares
(NPCI, CCIL, SWIFT, Umbrella Organisation, IDPIC) — held as AFS. No other company's shares.
</p> </p>
</div> </div>
@if (activeTab === 'holdings') { @if (activeTab === 'holdings') {
@@ -47,7 +48,7 @@
[class.pg-btn--active]="activeTab === 'dividend'" [class.pg-btn--active]="activeTab === 'dividend'"
(click)="setTab('dividend')" (click)="setTab('dividend')"
> >
02 Share Interest / Dividend Entry 02 Share Dividend Entry
</button> </button>
<button <button
type="button" type="button"
@@ -55,7 +56,7 @@
[class.pg-btn--active]="activeTab === 'sell'" [class.pg-btn--active]="activeTab === 'sell'"
(click)="setTab('sell')" (click)="setTab('sell')"
> >
03 Share Sell Entry 03 Share Refund / Retirement
</button> </button>
<button <button
type="button" type="button"
@@ -74,31 +75,63 @@
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Cert No</th> <th>Deal / Cert No</th>
<th>Institution</th>
<th>Script</th> <th>Script</th>
<th>Party</th> <th class="num">Shares</th>
<th class="num">Units</th>
<th class="num">Rate</th>
<th class="num">Face Value</th> <th class="num">Face Value</th>
<th class="num">Book Value</th>
<th class="num">Dividends</th>
<th>Purchase Date</th> <th>Purchase Date</th>
<th>Status</th> <th>Status</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (h of holdings; track h.certNo) { @for (d of deals; track d.id) {
<tr> <tr>
<td> <td>
<span class="pg-chip">{{ h.certNo }}</span> <span class="pg-chip">{{ d.dealNo }}</span> <small>{{ d.certNo }}</small>
</td> </td>
<td>{{ h.scriptName }}</td>
<td>{{ h.party }}</td>
<td class="num">{{ h.units | number }}</td>
<td class="num">₹{{ h.purchaseRate | number: '1.2-2' }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td>{{ h.purchaseDate }}</td>
<td> <td>
<span [class]="statusClass(h)">{{ h.status }}</span> {{ d.partyName }}<br /><small
>{{ institutionLabel(dealParty(d)?.institutionType)
}}{{ dealParty(d)?.inLiquidation ? ' · in liquidation' : '' }}</small
>
</td> </td>
<td>{{ d.scriptCode }} {{ d.scriptDesc }}</td>
<td class="num">{{ d.balanceUnits | number: '1.0-0' }}</td>
<td class="num">₹{{ faceHeld(d) | number: '1.2-2' }}</td>
<td class="num">₹{{ carryingValue(d) | number: '1.2-2' }}</td>
<td class="num">₹{{ dividendsReceived(d) | number: '1.2-2' }}</td>
<td>{{ d.settlementDate }}</td>
<td>
<span [class]="statusClass(d.status)">{{ d.status }}</span>
</td>
<td>
@if (d.status === 'PENDING_SETTLEMENT') {
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="settlingDealId !== null"
(click)="settleDeal(d)"
>
Settle
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="settlingDealId !== null"
(click)="dropDeal(d)"
>
Drop
</button>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="pg-empty">No share holdings yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -106,143 +139,408 @@
</div> </div>
} }
<!-- PURCHASE ENTRY --> <!-- 01 PURCHASE ENTRY -->
@if (activeTab === 'purchase') { @if (activeTab === 'purchase') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Investment - Share Purchase Entry</div> <div class="pos-section-label">Investment - Share Purchase Entry (AFS)</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"
><span>Cert No</span><input type="text" [(ngModel)]="purchaseForm.certNo" readonly
/></label>
<label class="inv-form-field"
><span>Script Code</span><input type="text" [(ngModel)]="purchaseForm.scriptCode"
/></label>
<label class="inv-form-field"
><span>Script/Company Name *</span><input type="text" [(ngModel)]="purchaseForm.scriptName"
/></label>
<label class="inv-form-field"
><span>Party / Exchange</span><input type="text" [(ngModel)]="purchaseForm.party"
/></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Sub Type</span> <span>* Institution</span>
<select [(ngModel)]="purchaseForm.subType"> <select [(ngModel)]="purchaseForm.partyId" (ngModelChange)="refreshLimit()">
<option value="EQUITY">EQUITY</option> <option [ngValue]="null">-- Select --</option>
<option value="CO-OP SHARE">CO-OP SHARE</option> @for (p of parties; track p.id) {
<option [ngValue]="p.id">{{ p.partyCode }} — {{ p.partyName }}</option>
}
</select>
</label>
@if (purchaseParty; as p) {
<label class="inv-form-field"
><span>Institution Type</span><input type="text" [value]="institutionLabel(p.institutionType)" readonly
/></label>
}
<label class="inv-form-field">
<span>* Share Script</span>
<select [(ngModel)]="purchaseForm.scriptId" (ngModelChange)="onScriptChange()">
<option [ngValue]="null">-- Select --</option>
@for (s of scripts; track s.id) {
<option [ngValue]="s.id">{{ s.scriptCode }} — {{ s.scriptDesc }}</option>
}
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>No. of Units *</span><input type="number" [(ngModel)]="purchaseForm.units" min="0" ><span>* Deal Date</span
/></label> ><mifosx-date-ddmmyyyy
<label class="inv-form-field" [(ngModel)]="purchaseForm.dealDate"
><span>Purchase Rate *</span><input type="number" [(ngModel)]="purchaseForm.purchaseRate" min="0" step="0.01" (ngModelChange)="refreshLimit()"
/></label> ></mifosx-date-ddmmyyyy
<label class="inv-form-field"
><span>Purchase Date *</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.purchaseDate"></mifosx-date-ddmmyyyy
></label> ></label>
<label class="inv-form-field">
<span>Settlement</span>
<select [(ngModel)]="purchaseForm.settlementType">
<option value="T+0">T+0</option>
<option value="T+1">T+1</option>
<option value="T+2">T+2</option>
</select>
</label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode" ><span>* No. of Shares</span
><input type="number" min="1" step="1" [(ngModel)]="purchaseForm.shares" (ngModelChange)="refreshLimit()"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>CR To A/c</span><input type="text" [(ngModel)]="purchaseForm.crToAc" ><span>Face Value per Share</span
><input
type="text"
[value]="purchaseScript ? '₹' + (purchaseScript.faceValuePerUnit | number: '1.2-2') : ''"
readonly
/></label>
<label class="inv-form-field"
><span>* Price per Share (₹)</span
><input
type="number"
min="0"
step="0.01"
[(ngModel)]="purchaseForm.pricePerShare"
(ngModelChange)="refreshLimit()"
/></label>
<label class="inv-form-field"
><span>Face Value</span><input type="text" [value]="'₹' + (purchaseFaceValue | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Amount Paid</span><input type="text" [value]="'₹' + (purchaseAmount | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field">
<span>* Paid from (Bank GL)</span>
<select [(ngModel)]="purchaseForm.bankGlCode">
@for (b of bankOptions; track b.glCode) {
<option [value]="b.glCode">{{ b.glCode }} — {{ b.glName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Category</span><input type="text" value="AFS (Available For Sale)" readonly
/></label> /></label>
</div> </div>
@if (shareLimit; as l) {
<div class="pos-section-label">
RBI Limit — shares of co-operative societies (owned funds as on {{ l.asOnDate }})
</div>
@if (!l.exempt) {
<table class="pg-table">
<thead>
<tr>
<th>Limit</th>
<th class="num">Allowed</th>
<th class="num">Now</th>
<th class="num">After this deal</th>
</tr>
</thead>
<tbody>
<tr>
<td>
All co-operative societies — 2% of owned funds
{{
l.ownedFunds !== null && l.ownedFunds !== undefined
? '₹' + (l.ownedFunds | number: '1.2-2')
: '(not entered)'
}}
</td>
<td class="num">
{{ l.coopLimit !== null && l.coopLimit !== undefined ? '₹' + (l.coopLimit | number: '1.2-2') : '—' }}
</td>
<td class="num">₹{{ l.coopOutstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ l.coopAfter | number: '1.2-2' }}</td>
</tr>
@if (l.partyName) {
<tr>
<td>
{{ l.partyName }} — 5% of subscribed capital
{{
l.subscribedCapital !== null && l.subscribedCapital !== undefined
? '₹' + (l.subscribedCapital | number: '1.2-2')
: '(not entered)'
}}
</td>
<td class="num">
{{
l.institutionLimit !== null && l.institutionLimit !== undefined
? '₹' + (l.institutionLimit | number: '1.2-2')
: '—'
}}
</td>
<td class="num">₹{{ l.institutionOutstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ l.institutionAfter | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
<p class="inv-hint" [class.inv-hint--bad]="!l.withinLimits">{{ l.message }}</p>
@if (!l.exempt && (l.ownedFunds === null || l.ownedFunds === undefined)) {
<div class="inv-form-grid">
<label class="inv-form-field"
><span>* Owned Funds as on {{ l.asOnDate }} (₹)</span
><input type="number" min="0" [(ngModel)]="ownedFunds" placeholder="Paid-up share capital + reserves"
/></label>
<label class="inv-form-field"
><span>Total Deposits as on {{ l.asOnDate }} (₹)</span
><input
type="number"
min="0"
[(ngModel)]="totalDeposits"
placeholder="Only if not entered on the FD screen"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveOwnedFunds()">
Save Owned Funds
</button>
</div>
}
}
@if (shareLimitError) {
<p class="inv-hint inv-hint--bad">{{ shareLimitError }}</p>
}
<div class="pos-section-label">Accounting (posted on Settle, dated the settlement date)</div>
<p class="inv-hint">
Dr 100706 Investment in Shares / Cr {{ purchaseForm.bankGlCode }} ₹{{ purchaseAmount | number: '1.2-2' }}
</p>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelPurchase()">Cancel</button> <button type="button" class="pg-btn pg-btn--ghost" (click)="cancelPurchase()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="savePurchase()">Save</button> <button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="saving || (shareLimit !== null && !shareLimit.withinLimits)"
(click)="savePurchase()"
>
Save
</button>
</div> </div>
</div> </div>
} }
<!-- DIVIDEND ENTRY --> <!-- 02 DIVIDEND ENTRY -->
@if (activeTab === 'dividend') { @if (activeTab === 'dividend') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Investment - Share Interest / Dividend Entry</div> <div class="pos-section-label">Investment - Share Dividend Entry (income when received)</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"> <label class="inv-form-field">
<span>Cert No *</span> <span>* Holding</span>
<select [(ngModel)]="dividendCertNo"> <select [(ngModel)]="dividend.purchaseId" (ngModelChange)="onDividendDealChange()">
<option value="">-- Select --</option> <option [ngValue]="null">-- Select --</option>
@for (h of activeHoldings; track h.certNo) { @for (d of dividendDeals; track d.id) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option> <option [ngValue]="d.id">
{{ d.dealNo }} — {{ d.partyName }} ({{ d.balanceUnits | number: '1.0-0' }} shares)
</option>
} }
</select> </select>
</label> </label>
@if (dividendHolding; as h) {
<label class="inv-form-field"><span>Units Held</span><input type="text" [value]="h.units" readonly /></label>
}
<label class="inv-form-field" <label class="inv-form-field"
><span>Dividend per Share *</span><input type="number" [(ngModel)]="dividendPerShare" min="0" step="0.01" ><span>* Date Received</span><mifosx-date-ddmmyyyy [(ngModel)]="dividend.dividendDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Dividend Amount (₹)</span><input type="number" min="0" step="0.01" [(ngModel)]="dividend.amount"
/></label> /></label>
<label class="inv-form-field">
<span>* Received in (Bank GL)</span>
<select [(ngModel)]="dividend.bankGlCode">
<option value="">-- Select --</option>
@for (b of bankOptions; track b.glCode) {
<option [value]="b.glCode">{{ b.glCode }} — {{ b.glName }}</option>
}
</select>
</label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Total Dividend (computed)</span ><span>Remarks</span
><input type="text" [value]="'₹' + (dividendAmount | number: '1.2-2')" readonly ><input type="text" maxlength="500" [(ngModel)]="dividend.remarks" placeholder="e.g. Dividend for FY 2025-26"
/></label> /></label>
</div> </div>
<p class="inv-hint">
Dr {{ dividend.bankGlCode || 'Bank' }} / Cr 400404 Dividend on Investments ₹{{
dividend.amount || 0 | number: '1.2-2'
}}
</p>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveDividend()">Save</button> <button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveDividend()">Save</button>
</div> </div>
</div> </div>
} }
<!-- SELL ENTRY --> <!-- 03 REFUND / RETIREMENT -->
@if (activeTab === 'sell') { @if (activeTab === 'sell') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Investment - Share Sell Entry</div> <div class="pos-section-label">Investment - Share Refund / Retirement Entry</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"> <label class="inv-form-field">
<span>Cert No *</span> <span>* Holding</span>
<select [(ngModel)]="sellCertNo"> <select [(ngModel)]="refund.purchaseId" (ngModelChange)="onRefundDealChange()">
<option value="">-- Select --</option> <option [ngValue]="null">-- Select --</option>
@for (h of activeHoldings; track h.certNo) { @for (d of activeDeals; track d.id) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option> <option [ngValue]="d.id">
{{ d.dealNo }} — {{ d.partyName }} ({{ d.balanceUnits | number: '1.0-0' }} shares)
</option>
} }
</select> </select>
</label> </label>
@if (sellHolding; as h) { <label class="inv-form-field"
<label class="inv-form-field"><span>Units Held</span><input type="text" [value]="h.units" readonly /></label> ><span>* Refund Date</span
><mifosx-date-ddmmyyyy
[(ngModel)]="refund.dealDate"
(ngModelChange)="refreshRefundQuote()"
></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Shares Refunded</span
><input type="number" min="1" step="1" [(ngModel)]="refund.units" (ngModelChange)="refreshRefundQuote()"
/></label>
<label class="inv-form-field"
><span>* Price per Share (₹)</span
><input
type="number"
min="0"
step="0.01"
[(ngModel)]="refund.pricePerShare"
(ngModelChange)="refreshRefundQuote()"
/></label>
<label class="inv-form-field">
<span>* Received in (Bank GL)</span>
<select [(ngModel)]="refund.bankGlCode" (ngModelChange)="refreshRefundQuote()">
<option value="">-- Select --</option>
@for (b of bankOptions; track b.glCode) {
<option [value]="b.glCode">{{ b.glCode }} — {{ b.glName }}</option>
} }
</select>
</label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Units to Sell *</span><input type="number" [(ngModel)]="sellUnits" min="0" ><span>Remarks</span><input type="text" maxlength="500" [(ngModel)]="refund.remarks"
/></label>
<label class="inv-form-field"
><span>Sell Rate *</span><input type="number" [(ngModel)]="sellRate" min="0" step="0.01"
/></label>
<label class="inv-form-field"
><span>Consideration (computed)</span
><input type="text" [value]="'₹' + (sellConsideration | number: '1.2-2')" readonly
/></label> /></label>
</div> </div>
@if (refundQuote; as q) {
<table class="pg-table">
<tbody>
<tr>
<td>Amount received</td>
<td class="num">₹{{ q.consideration | number: '1.2-2' }}</td>
</tr>
<tr>
<td>Book value taken off (Cr 100706)</td>
<td class="num">₹{{ q.bookValueSold | number: '1.2-2' }}</td>
</tr>
<tr>
<td>{{ q.profitLoss >= 0 ? 'Profit (Cr 400405)' : 'Loss (Dr 500605)' }}</td>
<td class="num">₹{{ (q.profitLoss >= 0 ? q.profitLoss : -q.profitLoss) | number: '1.2-2' }}</td>
</tr>
<tr>
<td>Settlement date</td>
<td class="num">{{ q.settlementDate }}</td>
</tr>
</tbody>
</table>
}
@if (refundQuoteError) {
<p class="inv-hint inv-hint--bad">{{ refundQuoteError }}</p>
}
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveSell()">Save</button> <button type="button" class="pg-btn pg-btn--primary" [disabled]="saving || !refundQuote" (click)="saveRefund()">
Save
</button>
</div> </div>
<div class="pos-section-label">Pending Refunds (posted on Settle)</div>
<table class="pg-table">
<thead>
<tr>
<th>Deal No</th>
<th>Institution</th>
<th class="num">Shares</th>
<th class="num">Amount</th>
<th class="num">Profit / Loss</th>
<th>Settlement Date</th>
<th></th>
</tr>
</thead>
<tbody>
@for (s of pendingRefunds; track s.id) {
<tr>
<td>
<span class="pg-chip">{{ s.dealNo }}</span>
</td>
<td>{{ s.partyName }}</td>
<td class="num">{{ s.units | number: '1.0-0' }}</td>
<td class="num">₹{{ s.netAmount | number: '1.2-2' }}</td>
<td class="num">
{{ s.profitLoss < 0 ? 'Loss' : 'Profit' }} ₹{{
(s.profitLoss < 0 ? -s.profitLoss : s.profitLoss) | number: '1.2-2'
}}
</td>
<td>{{ s.settlementDate }}</td>
<td>
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="busySaleId !== null"
(click)="settleRefund(s)"
>
Settle
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="busySaleId !== null"
(click)="dropRefund(s)"
>
Drop
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No pending refunds.</td>
</tr>
}
</tbody>
</table>
</div> </div>
} }
<!-- WRITE OFF ENTRY --> <!-- 04 WRITE OFF ENTRY -->
@if (activeTab === 'writeoff') { @if (activeTab === 'writeoff') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Investment - Share Write Off Entry</div> <div class="pos-section-label">Investment - Share Write Off Entry</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"> <label class="inv-form-field">
<span>Cert No *</span> <span>* Holding</span>
<select [(ngModel)]="writeoffCertNo"> <select [(ngModel)]="writeoffPurchaseId">
<option value="">-- Select --</option> <option [ngValue]="null">-- Select --</option>
@for (h of activeHoldings; track h.certNo) { @for (d of activeDeals; track d.id) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option> <option [ngValue]="d.id">{{ d.dealNo }} — {{ d.partyName }}</option>
} }
</select> </select>
</label> </label>
@if (writeoffHolding; as h) { @if (writeoffDeal; as d) {
<label class="inv-form-field" <label class="inv-form-field"
><span>Face Value to be Written Off</span ><span>Book Value</span><input type="text" [value]="'₹' + (carryingValue(d) | number: '1.2-2')" readonly
><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
/></label> /></label>
} }
<label class="inv-form-field"><span>Reason *</span><input type="text" [(ngModel)]="writeoffReason" /></label> <label class="inv-form-field"
><span>* Amount to Write Off (₹)</span><input type="number" min="0" step="0.01" [(ngModel)]="writeoffAmount"
/></label>
<label class="inv-form-field"><span>* Reason</span><input type="text" [(ngModel)]="writeoffReason" /></label>
</div> </div>
@if (writeoffDeal) {
<p class="inv-hint">
@if (writeoffInLiquidation) {
The institution is in liquidation: the shares may be written off in full; they stay held at nil until the
liquidator pays.
} @else {
Only part of the book value can be written off; enter a refund to close the holding.
}
Dr 500605 Loss on Investments / Cr 100706 Investment in Shares.
</p>
}
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveWriteoff()">Save</button> <button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveWriteoff()">Save</button>
</div> </div>
</div> </div>
} }
@@ -262,7 +560,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (t of txns; track t.voucherNo) { @for (t of txns; track $index) {
<tr> <tr>
<td>{{ t.txnDate }}</td> <td>{{ t.txnDate }}</td>
<td> <td>
@@ -273,6 +571,10 @@
<td>{{ t.particulars }}</td> <td>{{ t.particulars }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td> <td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No share transactions yet.</td>
</tr>
} }
</tbody> </tbody>
</table> </table>
@@ -6,31 +6,67 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { forkJoin } from 'rxjs';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component'; import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data'; import { InvestmentTxn, SETTLEMENT_BANK_GL_OPTIONS, todayDDMMYYYY } from '../investment-data';
import { exportWorkbook } from '../investment-export'; import { exportWorkbook } from '../investment-export';
import {
InvestmentDividendDto,
InvestmentPartyDto,
InvestmentPurchaseDto,
InvestmentSaleDto,
InvestmentScriptDto,
InvestmentService,
InvestmentShareLimitDto,
SHARE_INSTITUTION_TYPES,
apiErrorMessage,
awaitingApproval,
sentForApproval,
toPurchaseTxn,
toSaleTxn
} from '../investment.service';
type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff'; type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff';
/** Shares are always held as AFS. */
const SHARE_CATEGORY = 'AFS (Available For Sale)';
interface SharePurchaseForm { interface SharePurchaseForm {
certNo: string; partyId: number | null;
scriptCode: string; scriptId: number | null;
scriptName: string; dealDate: string;
party: string; settlementType: string;
subType: string; shares: number | null;
pricePerShare: number | null;
bankGlCode: string;
}
interface ShareRefundForm {
purchaseId: number | null;
dealDate: string;
units: number | null; units: number | null;
purchaseRate: number | null; pricePerShare: number | null;
purchaseDate: string; bankGlCode: string;
brokerCode: string; remarks: string;
crToAc: string; }
interface ShareDividendForm {
purchaseId: number | null;
dividendDate: string;
amount: number | null;
bankGlCode: string;
remarks: string;
} }
/** /**
* Share Entry treasury screen — Holdings register + Purchase / Dividend / Sell / Write-off * Share Entry — shares a UCB may hold under the RBI directions: of its DCCB / State Co-op Bank, of co-operative
* entry tabs (legacy "02 Share Entry" menu). Dummy in-memory data; Save appends locally. * societies in its area of operation, and membership shares of NPCI / CCIL / SWIFT, the Umbrella Organisation and
* IDPIC; no other company's shares. Always AFS. A purchase posts on Settle (Dr 100706 / Cr bank); a dividend is income
* when it is received (Dr bank / Cr 400404); a refund or retirement of shares is entered as a sale at the price paid
* back; shares of an institution in liquidation can be written off in full.
*/ */
@Component({ @Component({
selector: 'mifosx-share-investment', selector: 'mifosx-share-investment',
@@ -46,214 +82,607 @@ interface SharePurchaseForm {
DateDdmmyyyyPickerComponent DateDdmmyyyyPickerComponent
] ]
}) })
export class ShareInvestmentComponent { export class ShareInvestmentComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
activeTab: ShareTab = 'holdings'; activeTab: ShareTab = 'holdings';
asOnDate = todayDDMMYYYY(); asOnDate = todayDDMMYYYY();
holdings: InvestmentHolding[] = [...SHARE_HOLDINGS]; /** Share deals saved in the backend (dropped ones left out). */
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE'); deals: InvestmentPurchaseDto[] = [];
/** Refunds, retirements and write-offs of the share deals. */
sales: InvestmentSaleDto[] = [];
dividends: InvestmentDividendDto[] = [];
/** Every share institution, for the institution type and liquidation flag of a deal. */
private partyById = new Map<number, InvestmentPartyDto>();
/** Active share institutions and share scripts, offered for a new purchase. */
parties: InvestmentPartyDto[] = [];
scripts: InvestmentScriptDto[] = [];
readonly bankOptions = SETTLEMENT_BANK_GL_OPTIONS;
/** GL id for a GL code — the APIs take ids, the forms work with codes. */
private glIdByCode = new Map<string, number>();
generating = false; generating = false;
saving = false;
toastMsg = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
purchaseForm: SharePurchaseForm = this.blankPurchaseForm(); purchaseForm: SharePurchaseForm = this.blankPurchaseForm();
dividendCertNo = ''; shareLimit: InvestmentShareLimitDto | null = null;
dividendPerShare: number | null = null; shareLimitError = '';
sellCertNo = ''; ownedFunds: number | null = null;
sellUnits: number | null = null; totalDeposits: number | null = null;
sellRate: number | null = null; refund: ShareRefundForm = this.blankRefund();
writeoffCertNo = ''; refundQuote: InvestmentSaleDto | null = null;
refundQuoteError = '';
dividend: ShareDividendForm = this.blankDividend();
writeoffPurchaseId: number | null = null;
writeoffAmount: number | null = null;
writeoffReason = ''; writeoffReason = '';
ngOnInit(): void {
this.investmentService.getAssetGlAccounts().subscribe({
next: (gls) =>
(this.glIdByCode = new Map(
gls.map((g) => [
g.glCode,
g.id
])
)),
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
});
forkJoin({
parties: this.investmentService.getParties(),
scripts: this.investmentService.getScripts()
}).subscribe({
next: ({ parties, scripts }) => {
const shareParties = parties.filter((p) => p.bondType === 'SHARE');
this.partyById = new Map(
shareParties.map((p) => [
p.id,
p
])
);
this.parties = shareParties.filter((p) => p.active && !!p.institutionType);
this.scripts = scripts.filter((s) => s.bondType === 'SHARE' && s.active);
},
error: (err) => this.toast(`Could not load Script / Party masters: ${apiErrorMessage(err)}`)
});
this.load();
}
setTab(tab: ShareTab): void { setTab(tab: ShareTab): void {
this.activeTab = tab; this.activeTab = tab;
if (tab === 'purchase' && !this.shareLimit) {
this.refreshLimit();
}
} }
get activeHoldings(): InvestmentHolding[] { /** Holdings, refunds and dividends as saved in the backend. */
return this.holdings.filter((h) => h.status === 'ACTIVE'); private load(): void {
forkJoin({
purchases: this.investmentService.getPurchases(),
sales: this.investmentService.getSales(),
dividends: this.investmentService.getDividends()
}).subscribe({
next: ({ purchases, sales, dividends }) => {
this.deals = purchases.filter((p) => p.bondType === 'SHARE' && p.status !== 'DROPPED');
this.sales = sales.filter((s) => s.bondType === 'SHARE');
this.dividends = dividends;
},
error: (err) => this.toast(`Could not load share holdings: ${apiErrorMessage(err)}`)
});
} }
// -------------------- Holdings --------------------
institutionLabel(type?: string): string {
return SHARE_INSTITUTION_TYPES.find((t) => t.value === type)?.label ?? type ?? '';
}
dealParty(d: InvestmentPurchaseDto): InvestmentPartyDto | undefined {
return this.partyById.get(d.partyId);
}
/** Face value of the shares still held after any refund. */
faceHeld(d: InvestmentPurchaseDto): number {
return d.units ? Math.round((d.faceValue * d.balanceUnits * 100) / d.units) / 100 : d.faceValue;
}
/** Book value as it stands: cost less what refunds and write-offs have taken off. */
carryingValue(d: InvestmentPurchaseDto): number {
const taken = this.sales
.filter((x) => x.purchaseId === d.id && x.status === 'SETTLED')
.reduce((sum, x) => sum + x.bookValueSold, 0);
return Math.round((d.bookValue - taken) * 100) / 100;
}
dividendsReceived(d: InvestmentPurchaseDto): number {
return this.dividends.filter((x) => x.purchaseId === d.id).reduce((sum, x) => sum + x.amount, 0);
}
get activeDeals(): InvestmentPurchaseDto[] {
return this.deals.filter((d) => d.status === 'ACTIVE');
}
/** A dividend for a year the shares were held can come in after they were refunded. */
get dividendDeals(): InvestmentPurchaseDto[] {
return this.deals.filter((d) => d.status === 'ACTIVE' || d.status === 'SOLD');
}
get pendingRefunds(): InvestmentSaleDto[] {
return this.sales.filter((x) => x.status === 'PENDING_SETTLEMENT');
}
statusClass(status: string): string {
return `inv-badge inv-badge--${status.toLowerCase()}`;
}
/** The purchase being settled; its button stays disabled so it cannot be sent twice. */
settlingDealId: number | null = null;
/** Posts a pending deal to GL dated its settlement date: Dr 100706 Investment in Shares / Cr bank. */
settleDeal(d: InvestmentPurchaseDto): void {
if (this.settlingDealId !== null) {
return;
}
this.settlingDealId = d.id;
this.investmentService.settlePurchase(d.id).subscribe({
next: (res) => {
this.settlingDealId = null;
this.toast(
awaitingApproval(res)
? sentForApproval(`Settlement of deal ${d.dealNo}`)
: `Deal ${d.dealNo} settled and posted to GL (transaction ${res.transactionId}).`
);
this.load();
},
error: (err) => {
this.settlingDealId = null;
this.toast(apiErrorMessage(err));
}
});
}
dropDeal(d: InvestmentPurchaseDto): void {
if (this.settlingDealId !== null) {
return;
}
this.settlingDealId = d.id;
this.investmentService.deletePurchase(d.id).subscribe({
next: (res) => {
this.settlingDealId = null;
this.toast(awaitingApproval(res) ? sentForApproval(`Drop of deal ${d.dealNo}`) : `Deal ${d.dealNo} dropped.`);
this.load();
},
error: (err) => {
this.settlingDealId = null;
this.toast(apiErrorMessage(err));
}
});
}
// -------------------- 01 Purchase --------------------
private blankPurchaseForm(): SharePurchaseForm { private blankPurchaseForm(): SharePurchaseForm {
return { return {
certNo: `SH-${6000 + this.holdings.length + 1}`, partyId: null,
scriptCode: '', scriptId: null,
scriptName: '', dealDate: todayDDMMYYYY(),
party: 'NSE/BSE', settlementType: 'T+0',
subType: 'EQUITY', shares: null,
units: null, pricePerShare: null,
purchaseRate: null, bankGlCode: SETTLEMENT_BANK_GL_OPTIONS[0].glCode
purchaseDate: this.asOnDate,
brokerCode: '',
crToAc: '310070'
}; };
} }
get purchaseScript(): InvestmentScriptDto | undefined {
return this.scripts.find((s) => s.id === this.purchaseForm.scriptId);
}
get purchaseParty(): InvestmentPartyDto | undefined {
return this.parties.find((p) => p.id === this.purchaseForm.partyId);
}
get purchaseFaceValue(): number {
const s = this.purchaseScript;
return s && this.purchaseForm.shares ? Math.round(this.purchaseForm.shares * s.faceValuePerUnit * 100) / 100 : 0;
}
/** Cost of the shares: what Settle debits to 100706. */
get purchaseAmount(): number {
const f = this.purchaseForm;
return f.shares && f.pricePerShare ? Math.round(f.shares * f.pricePerShare * 100) / 100 : 0;
}
/** Co-operative shares are taken at face value: the price defaults to it. */
onScriptChange(): void {
this.purchaseForm.pricePerShare = this.purchaseScript?.faceValuePerUnit ?? null;
this.refreshLimit();
}
/** Each limit request is numbered so a slower, older response cannot overwrite the latest one. */
private limitSeq = 0;
/** Where the deal stands against the RBI limits on shares (2% of owned funds; 5% of one society's capital). */
refreshLimit(): void {
const f = this.purchaseForm;
const seq = ++this.limitSeq;
this.shareLimitError = '';
if (!f.dealDate) {
this.shareLimit = null;
return;
}
this.investmentService.getShareLimit(f.partyId, f.dealDate, this.purchaseAmount).subscribe({
next: (l) => {
if (seq === this.limitSeq) {
this.shareLimit = l;
}
},
error: (err) => {
if (seq === this.limitSeq) {
this.shareLimit = null;
this.shareLimitError = apiErrorMessage(err);
}
}
});
}
/** Owned funds (paid-up share capital + reserves) as on 31 March, kept with that year's total deposits. */
saveOwnedFunds(): void {
const asOn = this.shareLimit?.asOnDate;
if (!asOn || !this.ownedFunds || this.ownedFunds <= 0) {
this.toast(`Enter the owned funds as on ${asOn ?? '31 March'}.`);
return;
}
this.saving = true;
this.investmentService
.setDepositBase({
asOnDate: asOn,
ownedFunds: this.ownedFunds,
...(this.totalDeposits ? { totalDeposits: this.totalDeposits } : {}),
locale: 'en',
dateFormat: 'dd-MM-yyyy'
})
.subscribe({
next: (res) => {
this.saving = false;
this.toast(
awaitingApproval(res) ? sentForApproval(`Owned funds as on ${asOn}`) : `Owned funds as on ${asOn} saved.`
);
this.refreshLimit();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
}
savePurchase(): void { savePurchase(): void {
const f = this.purchaseForm; const f = this.purchaseForm;
if (!f.scriptName || !f.units || !f.purchaseRate) { const script = this.purchaseScript;
this.toast('Please fill Script Name, No. of Units and Purchase Rate.'); const crGlAccountId = this.glIdByCode.get(f.bankGlCode);
if (!f.partyId || !script || !f.shares || !f.pricePerShare || !f.dealDate) {
this.toast('Select the institution and the share script, and enter the number of shares and price per share.');
return; return;
} }
const faceValue = f.units * f.purchaseRate; if (!Number.isInteger(f.shares)) {
const holding: InvestmentHolding = { this.toast('Enter a whole number of shares.');
certNo: f.certNo, return;
instrumentType: 'SHARE', }
scriptCode: f.scriptCode || f.scriptName, if (!crGlAccountId) {
scriptName: f.scriptName, this.toast(`GL ${f.bankGlCode} was not found in the chart of accounts.`);
party: f.party, return;
subType: f.subType, }
faceValue, const body = {
purchaseRate: f.purchaseRate, bondType: 'SHARE',
units: f.units, scriptId: script.id,
purchaseDate: f.purchaseDate, partyId: f.partyId,
maturityDate: f.purchaseDate, dealDate: f.dealDate,
interestRate: 0, dealTime: '',
interestFreq: 'YEARLY', settlementType: f.settlementType,
glCode: '310060', faceValue: this.purchaseFaceValue,
glName: 'INVESTMENT IN SHARES', purchaseRate: f.pricePerShare,
crToAc: f.crToAc, category: SHARE_CATEGORY,
brokerCode: f.brokerCode, crGlAccountId,
status: 'ACTIVE' subType: '',
dateFormat: 'dd-MM-yyyy',
locale: 'en'
}; };
this.holdings = [ this.saving = true;
holding, this.investmentService.createPurchase(body).subscribe({
...this.holdings next: (res) => {
]; this.saving = false;
this.txns = [ this.toast(
{ awaitingApproval(res)
certNo: holding.certNo, ? sentForApproval('Share purchase')
instrumentType: 'SHARE', : `Deal ${res.resourceIdentifier} saved — pending settlement. Settle it from Holdings to post it.`
txnType: 'PURCHASE', );
voucherNo: `INV/PUR/${holding.certNo}`,
txnDate: f.purchaseDate,
amount: faceValue,
particulars: `Purchase of ${f.units} shares - ${holding.scriptName}`
},
...this.txns
];
this.toast(`Share holding ${holding.certNo} saved successfully.`);
this.purchaseForm = this.blankPurchaseForm(); this.purchaseForm = this.blankPurchaseForm();
this.shareLimit = null;
this.activeTab = 'holdings'; this.activeTab = 'holdings';
this.load();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
} }
cancelPurchase(): void { cancelPurchase(): void {
this.purchaseForm = this.blankPurchaseForm(); this.purchaseForm = this.blankPurchaseForm();
this.shareLimit = null;
} }
// -------------------- Dividend -------------------- // -------------------- 02 Dividend --------------------
get dividendHolding(): InvestmentHolding | undefined { private blankDividend(): ShareDividendForm {
return this.holdings.find((h) => h.certNo === this.dividendCertNo); return { purchaseId: null, dividendDate: todayDDMMYYYY(), amount: null, bankGlCode: '', remarks: '' };
} }
get dividendAmount(): number { get dividendDeal(): InvestmentPurchaseDto | undefined {
const h = this.dividendHolding; return this.deals.find((d) => d.id === this.dividend.purchaseId);
if (!h || !this.dividendPerShare) {
return 0;
}
return Math.round(h.units * this.dividendPerShare);
} }
onDividendDealChange(): void {
this.dividend.bankGlCode = this.dividendDeal?.crGlCode ?? '';
}
/** Dr bank / Cr 400404 Dividend on Investments, dated the day the dividend was received. */
saveDividend(): void { saveDividend(): void {
const h = this.dividendHolding; const f = this.dividend;
if (!h || !this.dividendPerShare) { const d = this.dividendDeal;
this.toast('Select a holding and enter dividend per share.'); const bankGlAccountId = this.glIdByCode.get(f.bankGlCode);
if (!d || !f.amount || f.amount <= 0 || !f.dividendDate || !bankGlAccountId) {
this.toast('Select the holding and the bank, and enter the dividend date and amount received.');
return; return;
} }
const amt = this.dividendAmount; const amount = f.amount;
this.txns = [ this.saving = true;
{ this.investmentService
certNo: h.certNo, .postDividend(d.id, {
instrumentType: 'SHARE', dividendDate: f.dividendDate,
txnType: 'DIVIDEND', amount,
voucherNo: `INV/DIV/${h.certNo}-${this.txns.length + 1}`, bankGlAccountId,
txnDate: this.asOnDate, ...(f.remarks.trim() ? { remarks: f.remarks.trim() } : {}),
amount: amt, dateFormat: 'dd-MM-yyyy',
particulars: `Dividend received - ${h.scriptName} (₹${this.dividendPerShare}/share)` locale: 'en'
})
.subscribe({
next: (res) => {
this.saving = false;
this.toast(
awaitingApproval(res)
? sentForApproval(`Dividend of ₹${amount} on deal ${d.dealNo}`)
: `Dividend of ₹${amount} on deal ${d.dealNo} posted — voucher ${res.resourceIdentifier ?? ''}.`
);
this.dividend = this.blankDividend();
this.load();
}, },
...this.txns error: (err) => {
]; this.saving = false;
this.toast(`Dividend of ₹${amt.toLocaleString('en-IN')} recorded for ${h.certNo}.`); this.toast(apiErrorMessage(err));
this.dividendCertNo = ''; }
this.dividendPerShare = null; });
} }
// -------------------- Sell -------------------- // -------------------- 03 Refund / Retirement --------------------
get sellHolding(): InvestmentHolding | undefined { private blankRefund(): ShareRefundForm {
return this.holdings.find((h) => h.certNo === this.sellCertNo); return {
purchaseId: null,
dealDate: todayDDMMYYYY(),
units: null,
pricePerShare: null,
bankGlCode: '',
remarks: ''
};
} }
get sellConsideration(): number { get refundDeal(): InvestmentPurchaseDto | undefined {
if (!this.sellUnits || !this.sellRate) { return this.deals.find((d) => d.id === this.refund.purchaseId);
return 0;
}
return Math.round(this.sellUnits * this.sellRate);
} }
saveSell(): void { /** The institution usually pays back the face value of the shares. */
const h = this.sellHolding; onRefundDealChange(): void {
if (!h || !this.sellUnits || !this.sellRate || this.sellUnits > h.units) { const d = this.refundDeal;
this.toast('Select a holding and enter valid units (≤ held) and sell rate.'); this.refund.units = d ? d.balanceUnits : null;
this.refund.pricePerShare = d && d.units ? Math.round((d.faceValue / d.units) * 100) / 100 : null;
this.refund.bankGlCode = d?.crGlCode ?? '';
this.refreshRefundQuote();
}
private refundQuoteSeq = 0;
/** Amount received, book value taken off and profit / loss, worked out by the backend as Settle will post them. */
refreshRefundQuote(): void {
const f = this.refund;
const seq = ++this.refundQuoteSeq;
this.refundQuote = null;
this.refundQuoteError = '';
if (!f.purchaseId || !f.units || !f.pricePerShare || !f.dealDate) {
return; return;
} }
const consideration = this.sellConsideration; const bankGlAccountId = this.glIdByCode.get(f.bankGlCode);
h.units -= this.sellUnits; this.investmentService
h.faceValue = Math.round(h.units * h.purchaseRate); .quoteSale({
if (h.units === 0) { purchaseId: f.purchaseId,
h.status = 'SOLD'; dealDate: f.dealDate,
settlementType: 'T+0',
units: f.units,
saleRate: f.pricePerShare,
...(bankGlAccountId ? { bankGlAccountId } : {})
})
.subscribe({
next: (q) => {
if (seq === this.refundQuoteSeq) {
this.refundQuote = q;
} }
this.txns = [
{
certNo: h.certNo,
instrumentType: 'SHARE',
txnType: 'SELL',
voucherNo: `INV/SEL/${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate,
amount: consideration,
particulars: `Sold ${this.sellUnits} shares of ${h.scriptName} @ ${this.sellRate}`
}, },
...this.txns error: (err) => {
]; if (seq === this.refundQuoteSeq) {
this.toast(`Sold ${this.sellUnits} shares. Consideration ₹${consideration.toLocaleString('en-IN')}.`); this.refundQuoteError = apiErrorMessage(err);
this.sellCertNo = ''; }
this.sellUnits = null; }
this.sellRate = null; });
this.activeTab = 'holdings';
} }
// -------------------- Write-off -------------------- /** Saves the refund pending; Settle posts Dr bank / Cr 100706, with any difference to 400405 / 500605. */
get writeoffHolding(): InvestmentHolding | undefined { saveRefund(): void {
return this.holdings.find((h) => h.certNo === this.writeoffCertNo); const f = this.refund;
const d = this.refundDeal;
const bankGlAccountId = this.glIdByCode.get(f.bankGlCode);
if (!d || !f.units || !f.pricePerShare || !bankGlAccountId) {
this.toast('Select the holding and the bank, and enter the shares refunded and the price per share.');
return;
}
const body = {
purchaseId: d.id,
// The institution itself pays the shares back.
partyId: d.partyId,
dealDate: f.dealDate,
dealTime: '',
settlementType: 'T+0',
units: f.units,
saleRate: f.pricePerShare,
bankGlAccountId,
...(f.remarks.trim() ? { remarks: f.remarks.trim() } : {}),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.saving = true;
this.investmentService.createSale(body).subscribe({
next: (res) => {
this.saving = false;
this.toast(
awaitingApproval(res) ? sentForApproval('Share refund') : 'Refund saved — settle it below to post it.'
);
this.refund = this.blankRefund();
this.refundQuote = null;
this.load();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
} }
/** The pending refund being settled or dropped; its buttons stay disabled so it cannot be sent twice. */
busySaleId: number | null = null;
settleRefund(s: InvestmentSaleDto): void {
if (!s.id || this.busySaleId !== null) {
return;
}
this.busySaleId = s.id;
this.investmentService.settleSale(s.id).subscribe({
next: (res) => {
this.busySaleId = null;
this.toast(
awaitingApproval(res)
? sentForApproval(`Settlement of the refund on deal ${s.dealNo}`)
: `Refund on deal ${s.dealNo} settled — voucher ${res.resourceIdentifier ?? ''}.`
);
this.load();
},
error: (err) => {
this.busySaleId = null;
this.toast(apiErrorMessage(err));
}
});
}
dropRefund(s: InvestmentSaleDto): void {
if (!s.id || this.busySaleId !== null) {
return;
}
this.busySaleId = s.id;
this.investmentService.deleteSale(s.id).subscribe({
next: (res) => {
this.busySaleId = null;
this.toast(
awaitingApproval(res)
? sentForApproval(`Drop of the pending refund on deal ${s.dealNo}`)
: `Pending refund on deal ${s.dealNo} dropped.`
);
this.load();
},
error: (err) => {
this.busySaleId = null;
this.toast(apiErrorMessage(err));
}
});
}
// -------------------- 04 Write Off --------------------
get writeoffDeal(): InvestmentPurchaseDto | undefined {
return this.deals.find((d) => d.id === this.writeoffPurchaseId);
}
/** Shares of an institution in liquidation may be written off in full; otherwise only part of the book value. */
get writeoffInLiquidation(): boolean {
const d = this.writeoffDeal;
return !!d && !!this.dealParty(d)?.inLiquidation;
}
/** Dr 500605 Loss on Investments / Cr 100706 Investment in Shares. */
saveWriteoff(): void { saveWriteoff(): void {
const h = this.writeoffHolding; const d = this.writeoffDeal;
if (!h || !this.writeoffReason) { const amount = this.writeoffAmount;
this.toast('Select a holding and enter a write-off reason.'); if (!d || !amount || amount <= 0 || !this.writeoffReason.trim()) {
this.toast('Select the holding, enter the amount and the reason.');
return; return;
} }
h.status = 'WRITTEN_OFF'; const book = this.carryingValue(d);
this.txns = [ if (this.writeoffInLiquidation ? amount > book : amount >= book) {
{ this.toast(
certNo: h.certNo, this.writeoffInLiquidation
instrumentType: 'SHARE', ? `The write-off cannot exceed the book value ₹${book}.`
txnType: 'WRITE_OFF', : 'The write-off must be less than the book value; only shares of an institution in liquidation can be written off in full.'
voucherNo: `INV/WOF/${h.certNo}`, );
txnDate: this.asOnDate, return;
amount: h.faceValue, }
particulars: `Written off - ${this.writeoffReason}` this.saving = true;
}, this.investmentService.writeOff(d.id, { amount, remarks: this.writeoffReason.trim(), locale: 'en' }).subscribe({
...this.txns next: (res) => {
]; this.saving = false;
this.toast(`${h.certNo} written off.`); this.toast(
this.writeoffCertNo = ''; awaitingApproval(res)
? sentForApproval(`Write-off of ₹${amount} on deal ${d.dealNo}`)
: `Deal ${d.dealNo}: ₹${amount} written off — voucher ${res.resourceIdentifier ?? ''}.`
);
this.writeoffPurchaseId = null;
this.writeoffAmount = null;
this.writeoffReason = ''; this.writeoffReason = '';
this.activeTab = 'holdings'; this.load();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
} }
statusClass(h: InvestmentHolding): string { // -------------------- Transaction log --------------------
return `inv-badge inv-badge--${h.status.toLowerCase()}`; /** Purchases, dividends, refunds and write-offs of the share deals, latest first. */
get txns(): InvestmentTxn[] {
const rows: InvestmentTxn[] = [
...this.deals.map(toPurchaseTxn),
...this.sales.filter((x) => x.status === 'SETTLED').map(toSaleTxn),
...this.dividends.map((x) => ({
certNo: x.certNo,
instrumentType: 'SHARE' as const,
txnType: 'DIVIDEND' as const,
voucherNo: x.voucherNo ?? `DIV/${x.dealNo}/${x.id}`,
txnDate: x.dividendDate,
amount: x.amount,
particulars: `Dividend - Deal ${x.dealNo} - ${x.scriptDesc} - ${x.partyName}${x.remarks ? ' - ' + x.remarks : ''}`,
glCode: x.bankGlCode,
glName: `${x.bankGlName} / Cr 400404 (txn ${x.glTransactionId})`
}))
];
const key = (d: string) => d.split('-').reverse().join('');
return rows.sort((a, b) => key(b.txnDate).localeCompare(key(a.txnDate)));
} }
// -------------------- Export to Excel (Holdings register) -------------------- // -------------------- Export to Excel (Holdings register) --------------------
@@ -267,34 +696,43 @@ export class ShareInvestmentComponent {
title: 'Share Holdings Register', title: 'Share Holdings Register',
subtitle: `As on ${this.asOnDate}`, subtitle: `As on ${this.asOnDate}`,
headers: [ headers: [
'Deal No',
'Cert No', 'Cert No',
'Institution',
'Institution Type',
'Script', 'Script',
'Party', 'Shares',
'Units',
'Purchase Rate',
'Face Value', 'Face Value',
'Book Value',
'Dividends Received',
'Purchase Date', 'Purchase Date',
'Status' 'Status'
], ],
widths: [ widths: [
14, 14,
14,
28,
24, 24,
22, 24,
12, 10,
14, 14,
16,
14, 14,
12 14,
14,
18
], ],
rows: this.holdings.map((h) => [ rows: this.deals.map((d) => [
h.certNo, d.dealNo,
h.scriptName, d.certNo,
h.party, d.partyName,
h.units, this.institutionLabel(this.dealParty(d)?.institutionType),
h.purchaseRate, `${d.scriptCode} ${d.scriptDesc}`,
h.faceValue, d.balanceUnits,
h.purchaseDate, this.faceHeld(d),
h.status this.carryingValue(d),
this.dividendsReceived(d),
d.settlementDate,
d.status
]) ])
} }
], ],
@@ -315,6 +753,6 @@ export class ShareInvestmentComponent {
if (this.toastTimer) { if (this.toastTimer) {
clearTimeout(this.toastTimer); clearTimeout(this.toastTimer);
} }
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); this.toastTimer = setTimeout(() => (this.toastVisible = false), 4000);
} }
} }