import { Injectable, NotFoundException } from '@nestjs/common'; import { decimalFromString, formatDecimal, multiply, } from '../../../../../common/decimal'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { decimal } from '../../../../../integrations/tbank/decimal'; import { TbankAccessService, type TbankDividend, } from '../../../../../integrations/tbank/tbank.api'; import { ForecastService, PortfolioService, } from '../../../portfolio/portfolio.api'; import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; import { moscowDay, timestamp } from '../../application/instrument-market'; import { instrumentPosition } from '../../application/instrument-position'; import { StockDividendDto, StockDividendsDto, StockDividendsResourceDto, StockPositionDto, } from '../presentation/stock.dto'; const INCOME_FORECAST_YEARS = 1; const ZERO_QUANTITY = 0n; const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; @Injectable() export class StockIncomeService { constructor( private readonly portfolios: PortfolioService, private readonly sources: CatalogEnrichmentService, private readonly access: TbankAccessService, private readonly forecasts: ForecastService, ) {} async readPosition(userId: string, uid: string, observe = false) { const saved = await this.portfolios.read(userId, 'all', observe); return { data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null, meta: { ...saved.meta, resourceKey: `stock/position/${uid}` }, }; } async dividends( userId: string, uid: string, observe = false, ): Promise { const connection = await this.access.local(userId); const today = moscowDay(new Date().toISOString()); const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const portfolio = await this.portfolios.read(userId, 'all', observe); const sources = await this.sources.readMany( userId, 'share', [uid], observe, ['detail', 'dividends'], { from, to }, ); if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { throw new NotFoundException(); } const events = sources[1].payload?.dividends; const position = portfolio.data ? instrumentPosition(portfolio.data, uid, 'STOCK') : null; const projected = events ? stockDividends( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, ) : undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, membershipRevision: connection.membershipRevision, scopeKey: `stock/dividends/${JSON.stringify([uid, from, to])}`, portfolio: portfolio.meta, sources, candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); return result.data || !projected ? result : { data: stockDividends( events ?? [], null, { from, to, today }, projected.observedAt, true, ), meta: { ...result.meta, availability: 'partial' }, }; } } export function stockDividends( events: TbankDividend[], position: null | StockPositionDto, window: { from: string; to: string; today: string }, observedAt: string, positionUnavailable: boolean, ): StockDividendsDto { const { from, to, today } = window; const quantity = position && position.accounts.length && position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? decimalFromString(position.quantity) : null; const dividends: StockDividendDto[] = events .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .map((event) => { const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const currency = event.dividendNet?.currency?.toUpperCase(); const perShare = currency && event.dividendNet ? { currency, value: formatDecimal(decimal(event.dividendNet)) } : null; // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. const eventDate = paymentDate ?? recordDate; const timing: StockDividendDto['timing'] = eventDate ? eventDate >= today ? 'upcoming' : 'past' : 'undated'; const estimatedAmount = quantity && perShare && recordDate && recordDate >= today && timing === 'upcoming' ? { currency: perShare.currency, value: formatDecimal( multiply(quantity, decimalFromString(perShare.value)), ), } : null; return { perShare, recordDate, lastBuyDate, paymentDate, timing, estimatedAmount, }; }) .sort((a, b) => (b.recordDate ?? '').localeCompare(a.recordDate ?? '')); return { dividends, from, to, observedAt: observedAt, positionAt: quantity ? (position?.observedAt ?? null) : null, estimateUnavailable: positionUnavailable, positionIsStale: position?.isStale ?? false, }; }