diff --git a/src/app/investment/bond-investment/bond-investment.component.html b/src/app/investment/bond-investment/bond-investment.component.html
index 0879664f2..2bed4eca8 100644
--- a/src/app/investment/bond-investment/bond-investment.component.html
+++ b/src/app/investment/bond-investment/bond-investment.component.html
@@ -1566,10 +1566,10 @@
- Each security still held is valued at the latest Daily Script Master rate / NAV on or before the date. Gains and
- losses are netted within Govt Securities, Corporate Bonds and Mutual Funds separately; a net loss is provided in
- the Investment Depreciation Reserve and a net gain is ignored. The book value does not change. HTM is not
- valued.
+ Each security still held is valued at the latest Daily Script Master rate / NAV on or before the date;
+ co-operative shares at face value, ₹1 or nil as RBI directs. Gains and losses are netted within Govt Securities,
+ Corporate Bonds, Mutual Funds and Shares separately; a net loss is provided in the Investment Depreciation
+ Reserve and a net gain is ignored. The book value does not change. HTM is not valued.
- Cert No
+ Deal / Cert No
+ Institution
Script
- Party
- Units
- Rate
+ Shares
Face Value
+ Book Value
+ Dividends
Purchase Date
Status
+
- @for (h of holdings; track h.certNo) {
+ @for (d of deals; track d.id) {
- {{ h.certNo }}
+ {{ d.dealNo }} {{ d.certNo }}
- {{ h.scriptName }}
- {{ h.party }}
- {{ h.units | number }}
- ₹{{ h.purchaseRate | number: '1.2-2' }}
- ₹{{ h.faceValue | number: '1.2-2' }}
- {{ h.purchaseDate }}
- {{ h.status }}
+ {{ d.partyName }}{{ institutionLabel(dealParty(d)?.institutionType)
+ }}{{ dealParty(d)?.inLiquidation ? ' · in liquidation' : '' }}
+ {{ d.scriptCode }} {{ d.scriptDesc }}
+ {{ d.balanceUnits | number: '1.0-0' }}
+ ₹{{ faceHeld(d) | number: '1.2-2' }}
+ ₹{{ carryingValue(d) | number: '1.2-2' }}
+ ₹{{ dividendsReceived(d) | number: '1.2-2' }}
+ {{ d.settlementDate }}
+
+ {{ d.status }}
+
+
+ @if (d.status === 'PENDING_SETTLEMENT') {
+
+ Settle
+
+
+ Drop
+
+ }
+
+
+ } @empty {
+
+ No share holdings yet.
}
@@ -106,143 +139,408 @@
}
-
+
@if (activeTab === 'purchase') {
}
-
+
@if (activeTab === 'dividend') {
}
-
+
@if (activeTab === 'sell') {
}
-
+
@if (activeTab === 'writeoff') {
}
@@ -262,7 +560,7 @@
- @for (t of txns; track t.voucherNo) {
+ @for (t of txns; track $index) {
{{ t.txnDate }}
@@ -273,6 +571,10 @@
{{ t.particulars }}
₹{{ t.amount | number: '1.2-2' }}
+ } @empty {
+
+ No share transactions yet.
+
}
diff --git a/src/app/investment/share-investment/share-investment.component.ts b/src/app/investment/share-investment/share-investment.component.ts
index 65e27ee4a..7e9404b31 100644
--- a/src/app/investment/share-investment/share-investment.component.ts
+++ b/src/app/investment/share-investment/share-investment.component.ts
@@ -6,31 +6,67 @@
* 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 { FormsModule } from '@angular/forms';
+import { forkJoin } from 'rxjs';
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 {
+ 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';
+/** Shares are always held as AFS. */
+const SHARE_CATEGORY = 'AFS (Available For Sale)';
+
interface SharePurchaseForm {
- certNo: string;
- scriptCode: string;
- scriptName: string;
- party: string;
- subType: string;
+ partyId: number | null;
+ scriptId: number | null;
+ dealDate: string;
+ settlementType: string;
+ shares: number | null;
+ pricePerShare: number | null;
+ bankGlCode: string;
+}
+
+interface ShareRefundForm {
+ purchaseId: number | null;
+ dealDate: string;
units: number | null;
- purchaseRate: number | null;
- purchaseDate: string;
- brokerCode: string;
- crToAc: string;
+ pricePerShare: number | null;
+ bankGlCode: string;
+ remarks: 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
- * entry tabs (legacy "02 Share Entry" menu). Dummy in-memory data; Save appends locally.
+ * Share Entry — shares a UCB may hold under the RBI directions: of its DCCB / State Co-op Bank, of co-operative
+ * 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({
selector: 'mifosx-share-investment',
@@ -46,214 +82,607 @@ interface SharePurchaseForm {
DateDdmmyyyyPickerComponent
]
})
-export class ShareInvestmentComponent {
+export class ShareInvestmentComponent implements OnInit {
+ private readonly investmentService = inject(InvestmentService);
+
activeTab: ShareTab = 'holdings';
asOnDate = todayDDMMYYYY();
- holdings: InvestmentHolding[] = [...SHARE_HOLDINGS];
- txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
+ /** Share deals saved in the backend (dropped ones left out). */
+ 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();
+ /** 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();
generating = false;
+ saving = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType | undefined;
purchaseForm: SharePurchaseForm = this.blankPurchaseForm();
- dividendCertNo = '';
- dividendPerShare: number | null = null;
- sellCertNo = '';
- sellUnits: number | null = null;
- sellRate: number | null = null;
- writeoffCertNo = '';
+ shareLimit: InvestmentShareLimitDto | null = null;
+ shareLimitError = '';
+ ownedFunds: number | null = null;
+ totalDeposits: number | null = null;
+ refund: ShareRefundForm = this.blankRefund();
+ refundQuote: InvestmentSaleDto | null = null;
+ refundQuoteError = '';
+ dividend: ShareDividendForm = this.blankDividend();
+ writeoffPurchaseId: number | null = null;
+ writeoffAmount: number | null = null;
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 {
this.activeTab = tab;
+ if (tab === 'purchase' && !this.shareLimit) {
+ this.refreshLimit();
+ }
}
- get activeHoldings(): InvestmentHolding[] {
- return this.holdings.filter((h) => h.status === 'ACTIVE');
+ /** Holdings, refunds and dividends as saved in the backend. */
+ 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 {
return {
- certNo: `SH-${6000 + this.holdings.length + 1}`,
- scriptCode: '',
- scriptName: '',
- party: 'NSE/BSE',
- subType: 'EQUITY',
- units: null,
- purchaseRate: null,
- purchaseDate: this.asOnDate,
- brokerCode: '',
- crToAc: '310070'
+ partyId: null,
+ scriptId: null,
+ dealDate: todayDDMMYYYY(),
+ settlementType: 'T+0',
+ shares: null,
+ pricePerShare: null,
+ bankGlCode: SETTLEMENT_BANK_GL_OPTIONS[0].glCode
};
}
+ 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 {
const f = this.purchaseForm;
- if (!f.scriptName || !f.units || !f.purchaseRate) {
- this.toast('Please fill Script Name, No. of Units and Purchase Rate.');
+ const script = this.purchaseScript;
+ 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;
}
- const faceValue = f.units * f.purchaseRate;
- const holding: InvestmentHolding = {
- certNo: f.certNo,
- instrumentType: 'SHARE',
- scriptCode: f.scriptCode || f.scriptName,
- scriptName: f.scriptName,
- party: f.party,
- subType: f.subType,
- faceValue,
- purchaseRate: f.purchaseRate,
- units: f.units,
- purchaseDate: f.purchaseDate,
- maturityDate: f.purchaseDate,
- interestRate: 0,
- interestFreq: 'YEARLY',
- glCode: '310060',
- glName: 'INVESTMENT IN SHARES',
- crToAc: f.crToAc,
- brokerCode: f.brokerCode,
- status: 'ACTIVE'
+ if (!Number.isInteger(f.shares)) {
+ this.toast('Enter a whole number of shares.');
+ return;
+ }
+ if (!crGlAccountId) {
+ this.toast(`GL ${f.bankGlCode} was not found in the chart of accounts.`);
+ return;
+ }
+ const body = {
+ bondType: 'SHARE',
+ scriptId: script.id,
+ partyId: f.partyId,
+ dealDate: f.dealDate,
+ dealTime: '',
+ settlementType: f.settlementType,
+ faceValue: this.purchaseFaceValue,
+ purchaseRate: f.pricePerShare,
+ category: SHARE_CATEGORY,
+ crGlAccountId,
+ subType: '',
+ dateFormat: 'dd-MM-yyyy',
+ locale: 'en'
};
- this.holdings = [
- holding,
- ...this.holdings
- ];
- this.txns = [
- {
- certNo: holding.certNo,
- instrumentType: 'SHARE',
- txnType: 'PURCHASE',
- voucherNo: `INV/PUR/${holding.certNo}`,
- txnDate: f.purchaseDate,
- amount: faceValue,
- particulars: `Purchase of ${f.units} shares - ${holding.scriptName}`
+ this.saving = true;
+ this.investmentService.createPurchase(body).subscribe({
+ next: (res) => {
+ this.saving = false;
+ this.toast(
+ awaitingApproval(res)
+ ? sentForApproval('Share purchase')
+ : `Deal ${res.resourceIdentifier} saved — pending settlement. Settle it from Holdings to post it.`
+ );
+ this.purchaseForm = this.blankPurchaseForm();
+ this.shareLimit = null;
+ this.activeTab = 'holdings';
+ this.load();
},
- ...this.txns
- ];
- this.toast(`Share holding ${holding.certNo} saved successfully.`);
- this.purchaseForm = this.blankPurchaseForm();
- this.activeTab = 'holdings';
+ error: (err) => {
+ this.saving = false;
+ this.toast(apiErrorMessage(err));
+ }
+ });
}
cancelPurchase(): void {
this.purchaseForm = this.blankPurchaseForm();
+ this.shareLimit = null;
}
- // -------------------- Dividend --------------------
- get dividendHolding(): InvestmentHolding | undefined {
- return this.holdings.find((h) => h.certNo === this.dividendCertNo);
+ // -------------------- 02 Dividend --------------------
+ private blankDividend(): ShareDividendForm {
+ return { purchaseId: null, dividendDate: todayDDMMYYYY(), amount: null, bankGlCode: '', remarks: '' };
}
- get dividendAmount(): number {
- const h = this.dividendHolding;
- if (!h || !this.dividendPerShare) {
- return 0;
- }
- return Math.round(h.units * this.dividendPerShare);
+ get dividendDeal(): InvestmentPurchaseDto | undefined {
+ return this.deals.find((d) => d.id === this.dividend.purchaseId);
}
+ onDividendDealChange(): void {
+ this.dividend.bankGlCode = this.dividendDeal?.crGlCode ?? '';
+ }
+
+ /** Dr bank / Cr 400404 Dividend on Investments, dated the day the dividend was received. */
saveDividend(): void {
- const h = this.dividendHolding;
- if (!h || !this.dividendPerShare) {
- this.toast('Select a holding and enter dividend per share.');
+ const f = this.dividend;
+ const d = this.dividendDeal;
+ 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;
}
- const amt = this.dividendAmount;
- this.txns = [
- {
- certNo: h.certNo,
- instrumentType: 'SHARE',
- txnType: 'DIVIDEND',
- voucherNo: `INV/DIV/${h.certNo}-${this.txns.length + 1}`,
- txnDate: this.asOnDate,
- amount: amt,
- particulars: `Dividend received - ${h.scriptName} (₹${this.dividendPerShare}/share)`
- },
- ...this.txns
- ];
- this.toast(`Dividend of ₹${amt.toLocaleString('en-IN')} recorded for ${h.certNo}.`);
- this.dividendCertNo = '';
- this.dividendPerShare = null;
+ const amount = f.amount;
+ this.saving = true;
+ this.investmentService
+ .postDividend(d.id, {
+ dividendDate: f.dividendDate,
+ amount,
+ bankGlAccountId,
+ ...(f.remarks.trim() ? { remarks: f.remarks.trim() } : {}),
+ dateFormat: 'dd-MM-yyyy',
+ 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();
+ },
+ error: (err) => {
+ this.saving = false;
+ this.toast(apiErrorMessage(err));
+ }
+ });
}
- // -------------------- Sell --------------------
- get sellHolding(): InvestmentHolding | undefined {
- return this.holdings.find((h) => h.certNo === this.sellCertNo);
+ // -------------------- 03 Refund / Retirement --------------------
+ private blankRefund(): ShareRefundForm {
+ return {
+ purchaseId: null,
+ dealDate: todayDDMMYYYY(),
+ units: null,
+ pricePerShare: null,
+ bankGlCode: '',
+ remarks: ''
+ };
}
- get sellConsideration(): number {
- if (!this.sellUnits || !this.sellRate) {
- return 0;
- }
- return Math.round(this.sellUnits * this.sellRate);
+ get refundDeal(): InvestmentPurchaseDto | undefined {
+ return this.deals.find((d) => d.id === this.refund.purchaseId);
}
- saveSell(): void {
- const h = this.sellHolding;
- if (!h || !this.sellUnits || !this.sellRate || this.sellUnits > h.units) {
- this.toast('Select a holding and enter valid units (≤ held) and sell rate.');
+ /** The institution usually pays back the face value of the shares. */
+ onRefundDealChange(): void {
+ const d = this.refundDeal;
+ 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;
}
- const consideration = this.sellConsideration;
- h.units -= this.sellUnits;
- h.faceValue = Math.round(h.units * h.purchaseRate);
- if (h.units === 0) {
- h.status = 'SOLD';
+ const bankGlAccountId = this.glIdByCode.get(f.bankGlCode);
+ this.investmentService
+ .quoteSale({
+ purchaseId: f.purchaseId,
+ dealDate: f.dealDate,
+ settlementType: 'T+0',
+ units: f.units,
+ saleRate: f.pricePerShare,
+ ...(bankGlAccountId ? { bankGlAccountId } : {})
+ })
+ .subscribe({
+ next: (q) => {
+ if (seq === this.refundQuoteSeq) {
+ this.refundQuote = q;
+ }
+ },
+ error: (err) => {
+ if (seq === this.refundQuoteSeq) {
+ this.refundQuoteError = apiErrorMessage(err);
+ }
+ }
+ });
+ }
+
+ /** Saves the refund pending; Settle posts Dr bank / Cr 100706, with any difference to 400405 / 500605. */
+ saveRefund(): void {
+ 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;
}
- 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}`
+ 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();
},
- ...this.txns
- ];
- this.toast(`Sold ${this.sellUnits} shares. Consideration ₹${consideration.toLocaleString('en-IN')}.`);
- this.sellCertNo = '';
- this.sellUnits = null;
- this.sellRate = null;
- this.activeTab = 'holdings';
+ error: (err) => {
+ this.saving = false;
+ this.toast(apiErrorMessage(err));
+ }
+ });
}
- // -------------------- Write-off --------------------
- get writeoffHolding(): InvestmentHolding | undefined {
- return this.holdings.find((h) => h.certNo === this.writeoffCertNo);
+ /** 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 {
- const h = this.writeoffHolding;
- if (!h || !this.writeoffReason) {
- this.toast('Select a holding and enter a write-off reason.');
+ const d = this.writeoffDeal;
+ const amount = this.writeoffAmount;
+ if (!d || !amount || amount <= 0 || !this.writeoffReason.trim()) {
+ this.toast('Select the holding, enter the amount and the reason.');
return;
}
- h.status = 'WRITTEN_OFF';
- this.txns = [
- {
- certNo: h.certNo,
- instrumentType: 'SHARE',
- txnType: 'WRITE_OFF',
- voucherNo: `INV/WOF/${h.certNo}`,
- txnDate: this.asOnDate,
- amount: h.faceValue,
- particulars: `Written off - ${this.writeoffReason}`
+ const book = this.carryingValue(d);
+ if (this.writeoffInLiquidation ? amount > book : amount >= book) {
+ this.toast(
+ this.writeoffInLiquidation
+ ? `The write-off cannot exceed the book value ₹${book}.`
+ : 'The write-off must be less than the book value; only shares of an institution in liquidation can be written off in full.'
+ );
+ return;
+ }
+ this.saving = true;
+ this.investmentService.writeOff(d.id, { amount, remarks: this.writeoffReason.trim(), locale: 'en' }).subscribe({
+ next: (res) => {
+ this.saving = false;
+ this.toast(
+ 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.load();
},
- ...this.txns
- ];
- this.toast(`${h.certNo} written off.`);
- this.writeoffCertNo = '';
- this.writeoffReason = '';
- this.activeTab = 'holdings';
+ error: (err) => {
+ this.saving = false;
+ this.toast(apiErrorMessage(err));
+ }
+ });
}
- statusClass(h: InvestmentHolding): string {
- return `inv-badge inv-badge--${h.status.toLowerCase()}`;
+ // -------------------- Transaction log --------------------
+ /** 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) --------------------
@@ -267,34 +696,43 @@ export class ShareInvestmentComponent {
title: 'Share Holdings Register',
subtitle: `As on ${this.asOnDate}`,
headers: [
+ 'Deal No',
'Cert No',
+ 'Institution',
+ 'Institution Type',
'Script',
- 'Party',
- 'Units',
- 'Purchase Rate',
+ 'Shares',
'Face Value',
+ 'Book Value',
+ 'Dividends Received',
'Purchase Date',
'Status'
],
widths: [
14,
+ 14,
+ 28,
24,
- 22,
- 12,
+ 24,
+ 10,
14,
- 16,
14,
- 12
+ 14,
+ 14,
+ 18
],
- rows: this.holdings.map((h) => [
- h.certNo,
- h.scriptName,
- h.party,
- h.units,
- h.purchaseRate,
- h.faceValue,
- h.purchaseDate,
- h.status
+ rows: this.deals.map((d) => [
+ d.dealNo,
+ d.certNo,
+ d.partyName,
+ this.institutionLabel(this.dealParty(d)?.institutionType),
+ `${d.scriptCode} ${d.scriptDesc}`,
+ d.balanceUnits,
+ this.faceHeld(d),
+ this.carryingValue(d),
+ this.dividendsReceived(d),
+ d.settlementDate,
+ d.status
])
}
],
@@ -315,6 +753,6 @@ export class ShareInvestmentComponent {
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
- this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
+ this.toastTimer = setTimeout(() => (this.toastVisible = false), 4000);
}
}