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.

@if (activeTab === 'holdings') { @@ -47,7 +48,7 @@ [class.pg-btn--active]="activeTab === 'dividend'" (click)="setTab('dividend')" > - 02 Share Interest / Dividend Entry + 02 Share Dividend Entry + + } + + + } @empty { + + No share holdings yet. } @@ -106,143 +139,408 @@ } - + @if (activeTab === 'purchase') {
-
Investment - Share Purchase Entry
+
Investment - Share Purchase Entry (AFS)
- - - - + @if (purchaseParty; as p) { + + } + - - + + + + + +
+ + @if (shareLimit; as l) { +
+ RBI Limit — shares of co-operative societies (owned funds as on {{ l.asOnDate }}) +
+ @if (!l.exempt) { + + + + + + + + + + + + + + + + + @if (l.partyName) { + + + + + + + } + +
LimitAllowedNowAfter this deal
+ All co-operative societies — 2% of owned funds + {{ + l.ownedFunds !== null && l.ownedFunds !== undefined + ? '₹' + (l.ownedFunds | number: '1.2-2') + : '(not entered)' + }} + + {{ l.coopLimit !== null && l.coopLimit !== undefined ? '₹' + (l.coopLimit | number: '1.2-2') : '—' }} + ₹{{ l.coopOutstanding | number: '1.2-2' }}₹{{ l.coopAfter | number: '1.2-2' }}
+ {{ l.partyName }} — 5% of subscribed capital + {{ + l.subscribedCapital !== null && l.subscribedCapital !== undefined + ? '₹' + (l.subscribedCapital | number: '1.2-2') + : '(not entered)' + }} + + {{ + l.institutionLimit !== null && l.institutionLimit !== undefined + ? '₹' + (l.institutionLimit | number: '1.2-2') + : '—' + }} + ₹{{ l.institutionOutstanding | number: '1.2-2' }}₹{{ l.institutionAfter | number: '1.2-2' }}
+ } +

{{ l.message }}

+ @if (!l.exempt && (l.ownedFunds === null || l.ownedFunds === undefined)) { +
+ + +
+
+ +
+ } + } + @if (shareLimitError) { +

{{ shareLimitError }}

+ } + +
Accounting (posted on Settle, dated the settlement date)
+

+ Dr 100706 Investment in Shares / Cr {{ purchaseForm.bankGlCode }} ₹{{ purchaseAmount | number: '1.2-2' }} +

- +
} - + @if (activeTab === 'dividend') {
-
Investment - Share Interest / Dividend Entry
+
Investment - Share Dividend Entry (income when received)
- @if (dividendHolding; as h) { - - } + +
+

+ Dr {{ dividend.bankGlCode || 'Bank' }} / Cr 400404 Dividend on Investments ₹{{ + dividend.amount || 0 | number: '1.2-2' + }} +

- +
} - + @if (activeTab === 'sell') {
-
Investment - Share Sell Entry
+
Investment - Share Refund / Retirement Entry
- @if (sellHolding; as h) { - - } + +
+ @if (refundQuote; as q) { + + + + + + + + + + + + + + + + + + + +
Amount received₹{{ q.consideration | number: '1.2-2' }}
Book value taken off (Cr 100706)₹{{ q.bookValueSold | number: '1.2-2' }}
{{ q.profitLoss >= 0 ? 'Profit (Cr 400405)' : 'Loss (Dr 500605)' }}₹{{ (q.profitLoss >= 0 ? q.profitLoss : -q.profitLoss) | number: '1.2-2' }}
Settlement date{{ q.settlementDate }}
+ } + @if (refundQuoteError) { +

{{ refundQuoteError }}

+ }
- +
+ +
Pending Refunds (posted on Settle)
+ + + + + + + + + + + + + + @for (s of pendingRefunds; track s.id) { + + + + + + + + + + } @empty { + + + + } + +
Deal NoInstitutionSharesAmountProfit / LossSettlement Date
+ {{ s.dealNo }} + {{ s.partyName }}{{ s.units | number: '1.0-0' }}₹{{ s.netAmount | number: '1.2-2' }} + {{ s.profitLoss < 0 ? 'Loss' : 'Profit' }} ₹{{ + (s.profitLoss < 0 ? -s.profitLoss : s.profitLoss) | number: '1.2-2' + }} + {{ s.settlementDate }} + + +
No pending refunds.
} - + @if (activeTab === 'writeoff') {
Investment - Share Write Off Entry
- @if (writeoffHolding; as h) { + @if (writeoffDeal; as d) { } - + +
+ @if (writeoffDeal) { +

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

+ }
- +
} @@ -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); } }