refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
} from '../../../common/decimal';
|
||||
import { RequestContextService } from '../../../common/logging/request-context.service';
|
||||
import { StructuredLogger } from '../../../common/logging/structured-logger.service';
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { MoexBondEvent, MoexClient } from '../../../integrations/moex/moex.api';
|
||||
import { decimal } from '../../../integrations/tbank/decimal';
|
||||
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
TbankDividend,
|
||||
TbankInstrument,
|
||||
TbankPortfolio,
|
||||
} from '../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
UpcomingEventDto,
|
||||
UpcomingEventsDto,
|
||||
UpcomingEventsQueryDto,
|
||||
} from './upcoming-events.dto';
|
||||
|
||||
type Position = NonNullable<TbankPortfolio['positions']>[number];
|
||||
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
|
||||
|
||||
const positionsBatchSize = 5;
|
||||
const dividendCacheTtlMs = 15 * 60_000;
|
||||
const instrumentCacheTtlMs = 24 * 60 * 60_000;
|
||||
|
||||
@Injectable()
|
||||
export class UpcomingEventsService {
|
||||
private readonly dividendCache = new Map<
|
||||
string,
|
||||
CachedEntry<TbankDividend[]>
|
||||
>();
|
||||
private readonly instrumentCache = new Map<
|
||||
string,
|
||||
CachedEntry<TbankInstrument | null>
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly moex: MoexClient,
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
async get(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
query: UpcomingEventsQueryDto = {},
|
||||
): Promise<UpcomingEventsDto> {
|
||||
const window = dateWindow(query);
|
||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||
return this.access.withAccount(userId, accountId, ({ token }) =>
|
||||
this.forAccount(token, accountId, window),
|
||||
);
|
||||
}
|
||||
|
||||
async getAll(
|
||||
userId: string,
|
||||
query: UpcomingEventsQueryDto = {},
|
||||
): Promise<UpcomingEventsDto> {
|
||||
const window = dateWindow(query);
|
||||
return this.access.withSupportedAccounts(
|
||||
userId,
|
||||
async ({ token, accounts }) => {
|
||||
const events: (UpcomingEventDto & {
|
||||
account: { id: string; name: string };
|
||||
})[] = [];
|
||||
for (const account of accounts) {
|
||||
const result = await this.forAccount(token, account.id, window);
|
||||
events.push(
|
||||
...result.events.map((event) => ({
|
||||
...event,
|
||||
account: { id: account.id, name: account.name },
|
||||
})),
|
||||
);
|
||||
}
|
||||
events.sort(
|
||||
(left, right) =>
|
||||
left.date.localeCompare(right.date) ||
|
||||
left.account.id.localeCompare(right.account.id),
|
||||
);
|
||||
return {
|
||||
accountId: 'all',
|
||||
calculatedAt: new Date().toISOString(),
|
||||
events,
|
||||
incomeTotalsByCurrency: incomeTotals(events),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async forAccount(
|
||||
token: string,
|
||||
accountId: string,
|
||||
window: { from: string; to: string },
|
||||
): Promise<UpcomingEventsDto> {
|
||||
const portfolio = await this.tbank.getPortfolio(token, accountId);
|
||||
const supportedPositions = (portfolio.positions ?? []).filter(supported);
|
||||
const events: UpcomingEventDto[][] = [];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < supportedPositions.length;
|
||||
offset += positionsBatchSize
|
||||
) {
|
||||
const batch = supportedPositions.slice(
|
||||
offset,
|
||||
offset + positionsBatchSize,
|
||||
);
|
||||
events.push(
|
||||
...(await Promise.all(
|
||||
batch.map((position) => this.positionEvents(token, position, window)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
const projected = events
|
||||
.flat()
|
||||
.filter((event) => event.date >= window.from && event.date <= window.to);
|
||||
this.logger.info('upcoming-events.calculated', {
|
||||
count: projected.length,
|
||||
});
|
||||
return {
|
||||
accountId,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
events: projected.sort(
|
||||
(left, right) =>
|
||||
left.date.localeCompare(right.date) ||
|
||||
left.type.localeCompare(right.type) ||
|
||||
left.ticker.localeCompare(right.ticker),
|
||||
),
|
||||
incomeTotalsByCurrency: incomeTotals(projected),
|
||||
};
|
||||
}
|
||||
|
||||
private async positionEvents(
|
||||
token: string,
|
||||
position: Position,
|
||||
window: { from: string; to: string },
|
||||
): Promise<UpcomingEventDto[]> {
|
||||
const ticker = position.ticker?.trim();
|
||||
const instrument = ticker
|
||||
? await this.instrument(token, position).catch(() => {
|
||||
this.logger.event('warn', 'upcoming-events.metadata-unavailable');
|
||||
return null;
|
||||
})
|
||||
: await this.instrument(token, position);
|
||||
const resolvedTicker = ticker || instrument?.ticker?.trim();
|
||||
if (!resolvedTicker) return [];
|
||||
const metadata = {
|
||||
figi: position.figi?.trim() || null,
|
||||
instrumentUid: position.instrumentUid?.trim() || null,
|
||||
ticker: resolvedTicker,
|
||||
displayName: instrument?.name?.trim() || resolvedTicker,
|
||||
isin: instrument?.isin?.trim() || null,
|
||||
logoName: instrument?.brand?.logoName?.trim() || null,
|
||||
};
|
||||
const quantity = decimal(position.quantity ?? {});
|
||||
if (position.instrumentType?.toLowerCase() === 'share') {
|
||||
const dividends = await this.dividends(token, position, window);
|
||||
this.logger.event('debug', 'upcoming-events.source-result', {
|
||||
count: dividends.length,
|
||||
});
|
||||
return dividends.map((event) => this.dividend(metadata, quantity, event));
|
||||
}
|
||||
const events = await this.moex.bondEvents(resolvedTicker);
|
||||
this.logger.event('debug', 'upcoming-events.source-result', {
|
||||
count: events.length,
|
||||
});
|
||||
return events.map((event) => this.bond(metadata, quantity, event));
|
||||
}
|
||||
|
||||
private async instrument(token: string, position: Position) {
|
||||
const instrumentUid = position.instrumentUid?.trim();
|
||||
if (instrumentUid)
|
||||
return this.cached(
|
||||
this.instrumentCache,
|
||||
instrumentUid,
|
||||
instrumentCacheTtlMs,
|
||||
() => this.tbank.getInstrumentByUid(token, instrumentUid),
|
||||
);
|
||||
const figi = position.figi?.trim();
|
||||
if (figi)
|
||||
return this.cached(this.instrumentCache, figi, instrumentCacheTtlMs, () =>
|
||||
this.tbank.getInstrumentByFigi(token, figi),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async dividends(
|
||||
token: string,
|
||||
position: Position,
|
||||
window: { from: string; to: string },
|
||||
): Promise<TbankDividend[]> {
|
||||
const instrumentId = position.instrumentUid ?? position.figi;
|
||||
if (!instrumentId) return [];
|
||||
const dividends = await this.cached(
|
||||
this.dividendCache,
|
||||
JSON.stringify([instrumentId, window.from, window.to]),
|
||||
dividendCacheTtlMs,
|
||||
() =>
|
||||
this.tbank.getDividends(token, instrumentId, window.from, window.to),
|
||||
);
|
||||
return dividends.filter(
|
||||
(event) =>
|
||||
event.dividendType !== 'Cancelled' && Boolean(event.recordDate),
|
||||
);
|
||||
}
|
||||
|
||||
private async cached<T>(
|
||||
cache: Map<string, CachedEntry<T>>,
|
||||
key: string,
|
||||
ttlMs: number,
|
||||
load: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const now = Date.now();
|
||||
const hit = cache.get(key);
|
||||
if (hit && hit.expiresAt > now) return hit.promise;
|
||||
const promise = load().catch((error: unknown) => {
|
||||
cache.delete(key);
|
||||
throw error;
|
||||
});
|
||||
cache.set(key, { expiresAt: now + ttlMs, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
private dividend(
|
||||
metadata: Pick<
|
||||
UpcomingEventDto,
|
||||
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
|
||||
>,
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
event: TbankDividend,
|
||||
): UpcomingEventDto {
|
||||
return {
|
||||
...metadata,
|
||||
type: 'dividend',
|
||||
date: new Date(Number(event.recordDate?.seconds) * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
dateKind: 'registry',
|
||||
projectedAmount: event.dividendNet
|
||||
? projectedMoney(
|
||||
quantity,
|
||||
formatDecimal(decimal(event.dividendNet)),
|
||||
event.dividendNet.currency,
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private bond(
|
||||
metadata: Pick<
|
||||
UpcomingEventDto,
|
||||
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
|
||||
>,
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
event: MoexBondEvent,
|
||||
): UpcomingEventDto {
|
||||
return {
|
||||
...metadata,
|
||||
type: event.type,
|
||||
date: event.date,
|
||||
dateKind: 'event',
|
||||
projectedAmount:
|
||||
'value' in event
|
||||
? projectedMoney(quantity, event.value, event.currency)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function supported(position: Position): boolean {
|
||||
return (
|
||||
!isZero(decimal(position.quantity ?? {})) &&
|
||||
['share', 'bond'].includes(position.instrumentType?.toLowerCase() ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
function dateWindow(query: UpcomingEventsQueryDto) {
|
||||
const from = query.from ?? new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${from}T00:00:00.000Z`);
|
||||
end.setUTCDate(end.getUTCDate() + 30);
|
||||
const to = query.to ?? end.toISOString().slice(0, 10);
|
||||
if (from > to)
|
||||
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
function projectedMoney(
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
value: string,
|
||||
currency: string | undefined,
|
||||
): MoneyDto | null {
|
||||
const normalizedCurrency = currency?.trim().toUpperCase();
|
||||
if (!normalizedCurrency) return null;
|
||||
return {
|
||||
currency: normalizedCurrency,
|
||||
value: formatDecimal(multiply(quantity, decimalFromString(value))),
|
||||
};
|
||||
}
|
||||
|
||||
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
|
||||
const totals = new Map<string, ReturnType<typeof decimal>>();
|
||||
for (const event of events) {
|
||||
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount)
|
||||
continue;
|
||||
const current = totals.get(event.projectedAmount.currency) ?? {
|
||||
value: 0n,
|
||||
scale: 0,
|
||||
};
|
||||
totals.set(
|
||||
event.projectedAmount.currency,
|
||||
add(current, decimalFromString(event.projectedAmount.value)),
|
||||
);
|
||||
}
|
||||
return [...totals]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
||||
}
|
||||
Reference in New Issue
Block a user