refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,288 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import {
add,
formatDecimal,
isZero,
multiply,
subtract,
} from '../../../../common/decimal';
import { RequestContextService } from '../../../../common/logging/request-context.service';
import { MoneyDto } from '../../../../common/money.dto';
import {
InstrumentMetadataService,
TbankAccessService,
TbankClient,
TbankConnectionContext,
TbankMoney,
} from '../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../integrations/tbank/decimal';
import { portfolioStructure } from '../domain/portfolio-structure';
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
import {
PortfolioDto,
PortfolioPositionDto,
} from '../presentation/portfolio.dto';
const freshForMs = 30_000;
const fallbackForMs = 300_000;
const positionsBatchSize = 5;
const metadataTimeoutMs = 30_000;
const types: Record<string, string> = {
share: 'STOCK',
bond: 'BOND',
etf: 'FUND',
currency: 'CURRENCY',
futures: 'FUTURE',
option: 'OPTION',
sp: 'STRUCTURED_NOTE',
dfa: 'DIGITAL_FINANCIAL_ASSET',
};
type CachedSnapshot = {
generation: string;
snapshot: PortfolioDto;
storedAt: number;
};
@Injectable()
export class PortfolioService {
private readonly snapshots = new Map<string, CachedSnapshot>();
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly metadata: InstrumentMetadataService,
) {}
async get(userId: string, accountId: string): Promise<PortfolioDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withConnection(userId, (connection) =>
this.snapshot(userId, accountId, connection),
);
}
async getAll(userId: string): Promise<PortfolioDto> {
return this.access.withSupportedAccounts(userId, async (context) => {
const portfolios: PortfolioDto[] = [];
for (const account of context.accounts) {
const portfolio = await this.snapshot(userId, account.id, context);
portfolios.push({
...portfolio,
positions: portfolio.positions.map((position) => ({
...position,
account: { id: account.id, name: account.name },
})),
});
}
return {
lastSuccessfulAt:
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
new Date().toISOString(),
isStale: portfolios.some((portfolio) => portfolio.isStale),
totalsByCurrency: sumMoneyByCurrency(
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
),
cashBalances: sumMoneyByCurrency(
portfolios.flatMap((portfolio) => portfolio.cashBalances),
),
positions: portfolios.flatMap((portfolio) => portfolio.positions),
structure: portfolioStructure(
portfolios.flatMap((portfolio) => portfolio.positions),
portfolios.flatMap((portfolio) => portfolio.cashBalances),
),
};
});
}
private async snapshot(
userId: string,
accountId: string,
{ token, generation }: TbankConnectionContext,
): Promise<PortfolioDto> {
const key = `${userId}:${accountId}`;
const cached = this.snapshots.get(key);
if (
cached?.generation === generation &&
Date.now() - cached.storedAt <= freshForMs
)
return cached.snapshot;
try {
const snapshot = await this.access.withAccountForConnection(
{ token, generation },
accountId,
async () => {
const [portfolio, positions] = await Promise.all([
this.tbank.getPortfolio(token, accountId),
this.tbank.getPositions(token, accountId),
]);
return this.normalize(
token,
portfolio.positions ?? [],
positions.money ?? [],
);
},
);
this.snapshots.set(key, {
generation,
snapshot,
storedAt: Date.parse(snapshot.lastSuccessfulAt),
});
return snapshot;
} catch (error) {
if (
error instanceof ServiceUnavailableException &&
cached?.generation === generation &&
Date.now() - cached.storedAt <= fallbackForMs
) {
return { ...cached.snapshot, isStale: true };
}
throw error;
}
}
invalidate(userId: string): void {
for (const key of this.snapshots.keys())
if (key.startsWith(`${userId}:`)) this.snapshots.delete(key);
}
private async normalize(
token: string,
upstream: {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
quantity?: { units?: string | number; nano?: string | number };
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney;
}[],
money: TbankMoney[],
): Promise<PortfolioDto> {
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), metadataTimeoutMs);
const positions: (PortfolioPositionDto | null)[] = [];
try {
for (
let offset = 0;
offset < upstream.length;
offset += positionsBatchSize
) {
positions.push(
...(await Promise.all(
upstream
.slice(offset, offset + positionsBatchSize)
.map((position) =>
this.position(token, position, controller.signal),
),
)),
);
}
} finally {
clearTimeout(timeout);
}
const cash = new Map<string, ReturnType<typeof decimal>>();
for (const value of money) {
const currency = value.currency?.trim().toUpperCase();
const amount = decimal(value);
if (!currency || isZero(amount)) continue;
cash.set(currency, add(cash.get(currency) ?? decimal({}), amount));
}
const cashBalances = [...cash].map(([currency, value]) => ({
currency,
value: formatDecimal(value),
}));
const normalizedPositions = positions.filter(
(position): position is PortfolioPositionDto => position !== null,
);
return {
lastSuccessfulAt,
isStale: false,
totalsByCurrency: totalsByCurrency(upstream, money),
positions: normalizedPositions,
cashBalances,
structure: portfolioStructure(normalizedPositions, cashBalances),
};
}
private async position(
token: string,
position: Parameters<PortfolioService['normalize']>[1][number],
signal: AbortSignal,
): Promise<PortfolioPositionDto | null> {
const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null;
const figi = position.figi?.trim() || null;
const instrumentUid = position.instrumentUid?.trim() || null;
const currentPrice = this.money(position.currentPrice);
const averagePurchasePrice = this.money(position.averagePositionPrice);
const currentValue = currentPrice && this.value(quantity, currentPrice);
const absoluteReturn =
currentPrice &&
averagePurchasePrice &&
currentPrice.currency === averagePurchasePrice.currency
? {
currency: currentPrice.currency,
value: formatDecimal(
multiply(
subtract(
decimalFromString(currentPrice.value),
decimalFromString(averagePurchasePrice.value),
),
quantity,
),
),
}
: null;
const metadata = await this.metadata.resolve(
token,
instrumentUid,
figi,
signal,
);
return {
figi,
instrumentUid,
displayName:
metadata?.name?.trim() ||
metadata?.ticker?.trim() ||
figi ||
'Unknown instrument',
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
quantity: formatDecimal(quantity),
currentValue,
averagePurchasePrice,
absoluteReturn,
isin: metadata?.isin?.trim() || null,
ticker: metadata?.ticker?.trim() || null,
logoName: metadata?.brand?.logoName?.trim() || null,
};
}
private value(
quantity: ReturnType<typeof decimal>,
price: MoneyDto,
): MoneyDto {
return {
currency: price.currency,
value: formatDecimal(multiply(quantity, decimalFromString(price.value))),
};
}
private money(value: TbankMoney | undefined): MoneyDto | null {
const currency = value?.currency?.trim().toUpperCase();
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
}
function decimalFromString(value: string) {
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
scale: fraction.length,
};
}