chore: strengthen backend eslint profile
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
|
||||
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
import {
|
||||
ForecastRepository,
|
||||
type ForecastSourceVersion,
|
||||
@@ -12,16 +14,16 @@ export class ForecastService {
|
||||
constructor(private readonly repository: ForecastRepository) {}
|
||||
|
||||
async result<T extends object>(input: {
|
||||
userId: string;
|
||||
revision: string;
|
||||
membershipRevision: number;
|
||||
scopeKey: string;
|
||||
portfolio: InvestmentResourceMetaDto;
|
||||
sources: {
|
||||
version: ForecastSourceVersion | null;
|
||||
meta: InvestmentResourceMetaDto;
|
||||
}[];
|
||||
candidate?: T;
|
||||
membershipRevision: number;
|
||||
portfolio: InvestmentResourceMetaDto;
|
||||
revision: string;
|
||||
scopeKey: string;
|
||||
sources: {
|
||||
meta: InvestmentResourceMetaDto;
|
||||
version: ForecastSourceVersion | null;
|
||||
}[];
|
||||
userId: string;
|
||||
}) {
|
||||
const dependencies = [
|
||||
input.portfolio,
|
||||
@@ -31,8 +33,8 @@ export class ForecastService {
|
||||
...input,
|
||||
portfolioProjection: input.portfolio.dataRevision,
|
||||
portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) =>
|
||||
dependency.resourceKey.startsWith('portfolio/')
|
||||
? [
|
||||
dependency.resourceKey.startsWith('portfolio/') ?
|
||||
[
|
||||
{
|
||||
accountId: decodeURIComponent(
|
||||
dependency.resourceKey.slice('portfolio/'.length),
|
||||
@@ -41,65 +43,65 @@ export class ForecastService {
|
||||
(dependency as InvestmentResourceMetaDto).dataRevision,
|
||||
),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
),
|
||||
sources: input.sources.flatMap((source) =>
|
||||
source.version ? [source.version] : [],
|
||||
),
|
||||
timestamps: dependencies.flatMap((dependency) =>
|
||||
dependency.lastSuccessfulAt
|
||||
? [
|
||||
dependency.lastSuccessfulAt ?
|
||||
[
|
||||
{
|
||||
resourceKey: dependency.resourceKey,
|
||||
at: dependency.lastSuccessfulAt,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
),
|
||||
});
|
||||
const timestamps = stored?.sourceTimestamps as
|
||||
{ resourceKey: string; at: string }[] | undefined;
|
||||
undefined | { at: string; resourceKey: string }[];
|
||||
const first = timestamps?.map((entry) => entry.at).sort()[0];
|
||||
const work =
|
||||
dependencies
|
||||
.map((dependency) => dependency.sync)
|
||||
.find((sync) => sync.state === 'running') ??
|
||||
dependencies
|
||||
.map((dependency) => dependency.sync)
|
||||
.find((sync) => sync.state === 'failed');
|
||||
dependencies
|
||||
.map((dependency) => dependency.sync)
|
||||
.find((sync) => sync.state === 'failed');
|
||||
const payload = stored ? (stored.payload as T) : null;
|
||||
const data =
|
||||
payload &&
|
||||
'positionIsStale' in payload &&
|
||||
'positionAt' in payload &&
|
||||
typeof payload.positionAt === 'string'
|
||||
? {
|
||||
typeof payload.positionAt === 'string' ?
|
||||
{
|
||||
...payload,
|
||||
positionIsStale: tbankSourceNeedsRefresh(
|
||||
'portfolio',
|
||||
new Date(payload.positionAt),
|
||||
new Date(Date.now()),
|
||||
),
|
||||
}
|
||||
: payload;
|
||||
} :
|
||||
payload;
|
||||
return {
|
||||
data,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: input.scopeKey,
|
||||
dataRevision: String(stored?.dataRevision ?? 0),
|
||||
availability: stored
|
||||
? 'complete'
|
||||
: dependencies.some(
|
||||
(dependency) => dependency.availability !== 'missing',
|
||||
)
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
availability: stored ?
|
||||
'complete' :
|
||||
dependencies.some(
|
||||
(dependency) => dependency.availability !== 'missing',
|
||||
) ?
|
||||
'partial' :
|
||||
'missing',
|
||||
fetchedAt: first ? new Date(first) : null,
|
||||
freshForMs:
|
||||
data && 'positionIsStale' in data && data.positionIsStale === true
|
||||
? 0
|
||||
: Number.POSITIVE_INFINITY,
|
||||
data && 'positionIsStale' in data && data.positionIsStale === true ?
|
||||
0 :
|
||||
Number.POSITIVE_INFINITY,
|
||||
work: work?.id ? { ...work, id: work.id } : null,
|
||||
dependencies,
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { setImmediate as immediate } from 'node:timers/promises';
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { setImmediate as immediate } from 'node:timers/promises';
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
describe('persistent portfolio projection', () => {
|
||||
const access = { local: jest.fn(), tokenForAttempt: jest.fn() };
|
||||
@@ -103,8 +103,9 @@ describe('persistent portfolio projection', () => {
|
||||
let index = 0;
|
||||
index < 10 && !repository.publish.mock.calls.length;
|
||||
index += 1
|
||||
)
|
||||
) {
|
||||
await immediate();
|
||||
}
|
||||
const snapshot = (
|
||||
repository.publish.mock.calls as [unknown, string, PortfolioDto][]
|
||||
)[0][2];
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { CandleService } from '../../instruments/instruments.api';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
PortfolioRepository,
|
||||
portfolioFreshForMs,
|
||||
portfolioWorkKey,
|
||||
} from '../infrastructure/portfolio.repository';
|
||||
import {
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
@@ -21,22 +14,30 @@ import {
|
||||
subtract,
|
||||
} 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 { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
InstrumentMetadataService,
|
||||
metadataKey,
|
||||
type TbankInstrument,
|
||||
type MetadataIdentity,
|
||||
metadataKey,
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
TbankMoney,
|
||||
type TbankInstrument,
|
||||
TbankMembershipService,
|
||||
TbankMoney,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { CandleService } from '../../instruments/instruments.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
|
||||
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
||||
import {
|
||||
portfolioFreshForMs,
|
||||
PortfolioRepository,
|
||||
portfolioWorkKey,
|
||||
} from '../infrastructure/portfolio.repository';
|
||||
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
|
||||
import {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
@@ -80,7 +81,7 @@ export class PortfolioService {
|
||||
userId,
|
||||
connection.revision,
|
||||
);
|
||||
if (discovery)
|
||||
if (discovery) {
|
||||
void discovery.done
|
||||
.then(async () => {
|
||||
const current = await this.access.local(userId, accountId);
|
||||
@@ -88,12 +89,13 @@ export class PortfolioService {
|
||||
for (const account of current.accounts.filter(
|
||||
(account) =>
|
||||
accountId === 'all' || account.accountId === accountId,
|
||||
))
|
||||
)) {
|
||||
await this.queueSnapshot(
|
||||
userId,
|
||||
current.revision,
|
||||
account.accountId,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
this.logger.event(
|
||||
@@ -102,22 +104,25 @@ export class PortfolioService {
|
||||
{ userId },
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const account of (discovery ? [] : connection.accounts).filter(
|
||||
(account) => accountId === 'all' || account.accountId === accountId,
|
||||
))
|
||||
)) {
|
||||
await this.queueSnapshot(
|
||||
userId,
|
||||
connection.revision,
|
||||
account.accountId,
|
||||
);
|
||||
}
|
||||
}
|
||||
const saved = await this.repository.read(userId, connection.revision);
|
||||
if (
|
||||
saved?.membershipAt &&
|
||||
accountId !== 'all' &&
|
||||
!saved.accounts.some((account) => account.accountId === accountId)
|
||||
)
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (!observe) {
|
||||
const identities = new Map<string, MetadataIdentity>();
|
||||
for (const account of (saved?.accounts ?? []).filter(
|
||||
@@ -125,17 +130,20 @@ export class PortfolioService {
|
||||
)) {
|
||||
const snapshot = account.portfolio?.payload as unknown as
|
||||
PortfolioDto | undefined;
|
||||
for (const position of snapshot?.positions ?? [])
|
||||
for (const position of snapshot?.positions ?? []) {
|
||||
identities.set(metadataKey(position), position);
|
||||
}
|
||||
}
|
||||
const savedMetadata = await this.metadata.savedMany([
|
||||
...identities.values(),
|
||||
]);
|
||||
for (const identity of identities.values())
|
||||
if (!savedMetadata.get(metadataKey(identity))?.fresh)
|
||||
for (const identity of identities.values()) {
|
||||
if (!savedMetadata.get(metadataKey(identity))?.fresh) {
|
||||
await this.metadata.request(userId, connection.revision, identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!observe)
|
||||
if (!observe) {
|
||||
void this.candles
|
||||
.prefetch(
|
||||
userId,
|
||||
@@ -156,6 +164,7 @@ export class PortfolioService {
|
||||
.catch(() =>
|
||||
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
|
||||
);
|
||||
}
|
||||
const membershipMeta = investmentResourceMeta({
|
||||
resourceKey: 'accounts/membership',
|
||||
dataRevision: String(saved?.membershipRevision ?? 0),
|
||||
@@ -175,15 +184,15 @@ export class PortfolioService {
|
||||
name: account.name,
|
||||
type: account.type,
|
||||
openedOn: account.openedOn,
|
||||
snapshot: account.portfolio
|
||||
? {
|
||||
snapshot: account.portfolio ?
|
||||
{
|
||||
...(account.portfolio.payload as unknown as PortfolioDto),
|
||||
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
|
||||
isStale:
|
||||
Date.now() - account.portfolio.fetchedAt.valueOf() >=
|
||||
portfolioFreshForMs,
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
||||
dataRevision: String(account.portfolio?.dataRevision ?? 0),
|
||||
@@ -209,8 +218,8 @@ export class PortfolioService {
|
||||
);
|
||||
const metadataDependencies = [...metadata.entries()].flatMap(
|
||||
([key, source]) =>
|
||||
source.complete && source.data
|
||||
? [
|
||||
source.complete && source.data ?
|
||||
[
|
||||
investmentResourceMeta({
|
||||
resourceKey: `instrument/metadata/${key}`,
|
||||
dataRevision: source.dataRevision,
|
||||
@@ -219,20 +228,20 @@ export class PortfolioService {
|
||||
freshForMs: tbankSourceTtlMs.metadata,
|
||||
work: null,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
);
|
||||
const portfolios = saved.accounts.flatMap((account) =>
|
||||
account.snapshot
|
||||
? [
|
||||
account.snapshot ?
|
||||
[
|
||||
{
|
||||
...account.snapshot,
|
||||
positions: account.snapshot.positions.map((position) => {
|
||||
const shared = metadata.get(metadataKey(position))?.data;
|
||||
return {
|
||||
...position,
|
||||
...(shared
|
||||
? {
|
||||
...(shared ?
|
||||
{
|
||||
displayName:
|
||||
shared.name?.trim() ||
|
||||
shared.ticker?.trim() ||
|
||||
@@ -241,28 +250,29 @@ export class PortfolioService {
|
||||
isin: shared.isin?.trim() || position.isin,
|
||||
logoName:
|
||||
shared.brand?.logoName?.trim() || position.logoName,
|
||||
}
|
||||
: {}),
|
||||
...(accountId === 'all'
|
||||
? { account: { id: account.id, name: account.name } }
|
||||
: {}),
|
||||
} :
|
||||
{}),
|
||||
...(accountId === 'all' ?
|
||||
{ account: { id: account.id, name: account.name } } :
|
||||
{}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
);
|
||||
const lastSuccessfulAt =
|
||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
||||
saved.membership.lastSuccessfulAt;
|
||||
const data =
|
||||
missing || !lastSuccessfulAt
|
||||
? null
|
||||
: {
|
||||
missing || !lastSuccessfulAt ?
|
||||
null :
|
||||
{
|
||||
lastSuccessfulAt,
|
||||
isStale: portfolios.length
|
||||
? portfolios.some((portfolio) => portfolio.isStale)
|
||||
: saved.membership.freshness !== 'fresh',
|
||||
isStale: portfolios.length ?
|
||||
portfolios.some((portfolio) => portfolio.isStale) :
|
||||
saved.membership.freshness !== 'fresh',
|
||||
totalsByCurrency: sumMoneyByCurrency(
|
||||
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
|
||||
),
|
||||
@@ -279,10 +289,10 @@ export class PortfolioService {
|
||||
saved.accounts
|
||||
.map((account) => account.meta.sync)
|
||||
.find((work) => work.state === 'running') ??
|
||||
saved.accounts
|
||||
.map((account) => account.meta.sync)
|
||||
.find((work) => work.state === 'failed') ??
|
||||
saved.membership.sync;
|
||||
saved.accounts
|
||||
.map((account) => account.meta.sync)
|
||||
.find((work) => work.state === 'failed') ??
|
||||
saved.membership.sync;
|
||||
return {
|
||||
data,
|
||||
meta: investmentResourceMeta({
|
||||
@@ -294,15 +304,15 @@ export class PortfolioService {
|
||||
]),
|
||||
metadataDependencies.map((dependency) => dependency.dataRevision),
|
||||
]),
|
||||
availability: missing
|
||||
? portfolios.length
|
||||
? 'partial'
|
||||
: 'missing'
|
||||
: 'complete',
|
||||
availability: missing ?
|
||||
portfolios.length ?
|
||||
'partial' :
|
||||
'missing' :
|
||||
'complete',
|
||||
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
|
||||
freshForMs: portfolios.length
|
||||
? portfolioFreshForMs
|
||||
: membershipFreshForMs,
|
||||
freshForMs: portfolios.length ?
|
||||
portfolioFreshForMs :
|
||||
membershipFreshForMs,
|
||||
work: work.id ? { ...work, id: work.id } : null,
|
||||
dependencies: [
|
||||
saved.membership,
|
||||
@@ -333,11 +343,12 @@ export class PortfolioService {
|
||||
force,
|
||||
);
|
||||
if (!requested) return null;
|
||||
if (!requested.launch)
|
||||
if (!requested.launch) {
|
||||
return {
|
||||
id: requested.work.id,
|
||||
done: this.pending.get(requested.work.attemptId) ?? Promise.resolve(),
|
||||
};
|
||||
}
|
||||
const { id, attemptId } = requested.work;
|
||||
this.active.add(attemptId);
|
||||
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
||||
@@ -360,22 +371,24 @@ export class PortfolioService {
|
||||
snapshot,
|
||||
);
|
||||
if (published) {
|
||||
for (const position of snapshot.positions)
|
||||
for (const position of snapshot.positions) {
|
||||
await this.metadata.request(userId, revision, position);
|
||||
if (!force)
|
||||
}
|
||||
if (!force) {
|
||||
await this.candles.prefetch(
|
||||
userId,
|
||||
revision,
|
||||
snapshot.positions,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'INVALID_TBANK_TOKEN'
|
||||
)
|
||||
) {
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
else await this.repository.fail(attempt);
|
||||
} else await this.repository.fail(attempt);
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
@@ -396,8 +409,9 @@ export class PortfolioService {
|
||||
|
||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||
const result = await this.read(userId, accountId);
|
||||
if (!result.data)
|
||||
if (!result.data) {
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -407,12 +421,12 @@ export class PortfolioService {
|
||||
|
||||
private async normalize(
|
||||
upstream: {
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
quantity?: { units?: string | number; nano?: string | number };
|
||||
currentPrice?: TbankMoney;
|
||||
averagePositionPrice?: TbankMoney;
|
||||
currentPrice?: TbankMoney;
|
||||
figi?: string;
|
||||
instrumentType?: string;
|
||||
instrumentUid?: string;
|
||||
quantity?: { nano?: number | string; units?: number | string };
|
||||
}[],
|
||||
money: TbankMoney[],
|
||||
): Promise<PortfolioDto> {
|
||||
@@ -450,8 +464,8 @@ export class PortfolioService {
|
||||
|
||||
private position(
|
||||
position: Parameters<PortfolioService['normalize']>[0][number],
|
||||
metadata: TbankInstrument | null,
|
||||
): PortfolioPositionDto | null {
|
||||
metadata: null | TbankInstrument,
|
||||
): null | PortfolioPositionDto {
|
||||
const quantity = decimal(position.quantity ?? {});
|
||||
if (isZero(quantity)) return null;
|
||||
const figi = position.figi?.trim() || null;
|
||||
@@ -462,8 +476,8 @@ export class PortfolioService {
|
||||
const absoluteReturn =
|
||||
currentPrice &&
|
||||
averagePurchasePrice &&
|
||||
currentPrice.currency === averagePurchasePrice.currency
|
||||
? {
|
||||
currentPrice.currency === averagePurchasePrice.currency ?
|
||||
{
|
||||
currency: currentPrice.currency,
|
||||
value: formatDecimal(
|
||||
multiply(
|
||||
@@ -474,8 +488,8 @@ export class PortfolioService {
|
||||
quantity,
|
||||
),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
} :
|
||||
null;
|
||||
return {
|
||||
figi,
|
||||
instrumentUid,
|
||||
@@ -507,8 +521,8 @@ export class PortfolioService {
|
||||
|
||||
private money(value: TbankMoney | undefined): MoneyDto | null {
|
||||
const currency = value?.currency?.trim().toUpperCase();
|
||||
return currency && value
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
return currency && value ?
|
||||
{ currency, value: formatDecimal(decimal(value)) } :
|
||||
null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user