diff --git a/src/app/investment/bond-investment/bond-investment.component.html b/src/app/investment/bond-investment/bond-investment.component.html index 8e1c1c440..37c544297 100644 --- a/src/app/investment/bond-investment/bond-investment.component.html +++ b/src/app/investment/bond-investment/bond-investment.component.html @@ -853,7 +853,7 @@ {{ r.type === 'COUPON' ? 'Coupon' : 'Provision' }} - @if (r.due) { + @if (r.due && !r.posted) { Due } diff --git a/src/app/investment/bond-investment/bond-investment.component.ts b/src/app/investment/bond-investment/bond-investment.component.ts index 72bc94367..157970240 100644 --- a/src/app/investment/bond-investment/bond-investment.component.ts +++ b/src/app/investment/bond-investment/bond-investment.component.ts @@ -58,6 +58,7 @@ import { exportWorkbook } from '../investment-export'; import { InvestmentDailyRateDto, InvestmentInterestDueDto, + InvestmentInterestPostingDto, InvestmentInterestRowDto, InvestmentInterestScheduleDto, InvestmentPurchaseDto, @@ -243,11 +244,42 @@ export class BondInvestmentComponent implements OnInit { this.holdings = purchases.map(toHolding); this.txns = purchases.map((d) => this.purchaseTxn(d)); this.purchaseForm = this.blankPurchaseForm(); + this.loadInterestLog(); }, error: (err) => this.toast(`Could not load purchases: ${apiErrorMessage(err)}`) }); } + /** Posted coupons / provisions join the purchases in the transaction log, latest date first. */ + private loadInterestLog(): void { + this.investmentService.getInterestPostings().subscribe({ + next: (postings) => { + const others = this.txns.filter((t) => !(t.txnType === 'INTEREST' && t.voucherNo.startsWith('JV/'))); + this.txns = [ + ...others, + ...postings.map((x) => this.interestTxn(x)) + ].sort((a, b) => parseDDMMYYYY(b.txnDate).getTime() - parseDDMMYYYY(a.txnDate).getTime()); + }, + error: () => undefined + }); + } + + /** A coupon shows the cash received into the bank; a provision the interest booked as receivable. */ + private interestTxn(x: InvestmentInterestPostingDto): InvestmentTxn { + const coupon = x.lineType === 'COUPON'; + return { + certNo: x.certNo, + instrumentType: x.bondType as InstrumentType, + txnType: 'INTEREST', + voucherNo: x.voucherNo, + txnDate: x.intDate, + amount: coupon ? x.grossInterest : x.amount, + particulars: `${coupon ? 'Coupon' : 'Provision'} ${x.fromDate} to ${x.toDate} - Deal ${x.dealNo} - ${x.scriptDesc}`, + glCode: x.drGlCode, + glName: `${x.drGlName} / Cr ${x.incomeGlCode} (txn ${x.glTransactionId})` + }; + } + /** A purchase reaches the GL only when it is settled, dated the settlement date. */ private purchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn { const posted = !!d.glTransactionId; @@ -1128,6 +1160,7 @@ export class BondInvestmentComponent implements OnInit { ); this.retrieveInterestSchedule(); this.loadDueInterest(); + this.loadInterestLog(); }, error: (err) => { this.intPosting = false; @@ -1531,8 +1564,20 @@ export class BondInvestmentComponent implements OnInit { } // -------------------- Helpers -------------------- + /** + * Interest earned since the last coupon up to the as-on date (30/360), as the backend schedule counts it. + * Coupons fall every six months from the purchase's last interest date; a coupon received resets it to zero. + */ accrued(h: InvestmentHolding): number { - return accruedInterest(h, this.asOnDate); + if (!h.lastIntDate || h.status !== 'ACTIVE') { + return h.lastIntDate ? 0 : accruedInterest(h, this.asOnDate); + } + const asOn = parseDDMMYYYY(this.asOnDate) > parseDDMMYYYY(h.maturityDate) ? h.maturityDate : this.asOnDate; + let lastCoupon = h.lastIntDate; + for (let k = 1; parseDDMMYYYY(addPeriod(h.lastIntDate, 0, 6 * k)) <= parseDDMMYYYY(asOn); k++) { + lastCoupon = addPeriod(h.lastIntDate, 0, 6 * k); + } + return Math.round((h.faceValue * (h.interestRate / 100) * days360(lastCoupon, asOn)) / 360); } maturity(h: InvestmentHolding): number { diff --git a/src/app/investment/investment-data.ts b/src/app/investment/investment-data.ts index cf69b9abe..34e830788 100644 --- a/src/app/investment/investment-data.ts +++ b/src/app/investment/investment-data.ts @@ -135,6 +135,8 @@ export interface InvestmentHolding { settlementAmount?: number; /** 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; } diff --git a/src/app/investment/investment-masters/investment-masters.component.ts b/src/app/investment/investment-masters/investment-masters.component.ts index 19bf54027..b0b0fa5e8 100644 --- a/src/app/investment/investment-masters/investment-masters.component.ts +++ b/src/app/investment/investment-masters/investment-masters.component.ts @@ -53,6 +53,10 @@ type MasterTab = * 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. */ +/** 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({ selector: 'mifosx-investment-masters', templateUrl: './investment-masters.component.html', @@ -120,7 +124,7 @@ export class InvestmentMastersComponent implements OnInit { saving = false; readonly bankTypeOptions = BANK_TYPE_OPTIONS; readonly slrTypeOptions = SLR_TYPE_OPTIONS; - /** Enabled ASSET detail GLs from the chart of accounts. */ + /** Investment GLs (under the 100700 INVESTMENTS head) a party or script can be mapped to — not loans, banks or test GLs. */ investmentGlOptions: InvestmentGlAccount[] = []; newSubType: Partial = {}; newBroker: Partial = {}; @@ -133,7 +137,10 @@ export class InvestmentMastersComponent implements OnInit { ngOnInit(): void { this.loadDailyRates(); this.investmentService.getAssetGlAccounts().subscribe({ - next: (gls) => (this.investmentGlOptions = gls), + 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.reloadMasters(); diff --git a/src/app/investment/investment.service.ts b/src/app/investment/investment.service.ts index 076b8f8c6..2ff933c63 100644 --- a/src/app/investment/investment.service.ts +++ b/src/app/investment/investment.service.ts @@ -175,6 +175,11 @@ export class InvestmentService { } /** Coupon and 31 Mar / 30 Sep provision lines of a holding up to maturity (calculated, not posted). */ + /** Interest lines already posted (coupons and provisions), newest first. */ + getInterestPostings(): Observable { + return this.http.get('/investment/interest-postings'); + } + /** Interest lines of all holdings that are due now or within the next few days. */ getDueInterest(): Observable { return this.http.get('/investment/interest-postings/due'); @@ -342,6 +347,7 @@ export function toHolding(p: InvestmentPurchaseDto): InvestmentHolding { brokerCode: '', status: p.status as HoldingStatus, settlementDate: p.settlementDate, + lastIntDate: p.lastIntDate, glTransactionId: p.glTransactionId, category: p.category, bookValue: p.bookValue, @@ -372,6 +378,30 @@ export interface InvestmentInterestRowDto { } /** A holding's next interest line that is due (or due within the reminder window) — a task to post. */ +/** An interest line already posted to GL with its JV voucher. */ +export interface InvestmentInterestPostingDto { + id: number; + purchaseId: number; + dealNo: string; + certNo: string; + bondType: string; + scriptDesc: string; + lineType: 'COUPON' | 'PROVISION'; + intDate: string; + fromDate: string; + toDate: string; + grossInterest: number; + alreadyBooked: number; + amount: number; + drGlCode: string; + drGlName: string; + incomeGlCode: string; + incomeGlName: string; + glTransactionId: string; + voucherNo: string; + postedOnDate: string; +} + export interface InvestmentInterestDueDto { purchaseId: number; dealNo: string;