chore: strengthen backend eslint profile
This commit is contained in:
@@ -21,3 +21,18 @@ describe('decimal', () => {
|
||||
).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decimal formatting', () => {
|
||||
it.each([
|
||||
[120000n, 4, '12'],
|
||||
[-120030n, 4, '-12.003'],
|
||||
[1n, 4, '0.0001'],
|
||||
[0n, 4, '0'],
|
||||
])('preserves value %s at scale %s while trimming only trailing zeros', (value, scale, expected) => {
|
||||
expect(formatDecimal({ value, scale })).toBe(expected);
|
||||
});
|
||||
it('preserves a long fraction with a trailing zero run', () => {
|
||||
const significant = '1'.repeat(10_000);
|
||||
expect(formatDecimal({ value: BigInt(significant + '0'.repeat(10_000)), scale: 20_000 })).toBe('0.' + significant);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Decimal } from '../../common/decimal';
|
||||
|
||||
const NANO = 1_000_000_000n;
|
||||
|
||||
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
||||
export type ProtoDecimal = { nano?: number | string; units?: number | string };
|
||||
|
||||
export function decimal(value: ProtoDecimal): Decimal {
|
||||
return {
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type {
|
||||
Prisma,
|
||||
TbankInstrumentLookup as MetadataLookupRow,
|
||||
TbankInstrument as MetadataRow,
|
||||
Prisma,
|
||||
} from '../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||
import type { TbankInstrument } from './tbank.client';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||
|
||||
export type MetadataIdentity = {
|
||||
instrumentUid?: string | null;
|
||||
figi?: string | null;
|
||||
figi?: null | string;
|
||||
instrumentUid?: null | string;
|
||||
};
|
||||
export type MetadataLookup = {
|
||||
type: 'uid' | 'figi';
|
||||
data: null | TbankInstrument;
|
||||
id: string;
|
||||
data: TbankInstrument | null;
|
||||
type: 'figi' | 'uid';
|
||||
};
|
||||
const provider = 'tbank';
|
||||
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
|
||||
@@ -36,15 +38,16 @@ export class InstrumentMetadataRepository {
|
||||
|
||||
async readMany(identities: MetadataIdentity[]) {
|
||||
const identifiers = identities.flatMap((identity) => [
|
||||
...(identity.instrumentUid?.trim()
|
||||
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
|
||||
: []),
|
||||
...(identity.figi?.trim()
|
||||
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
|
||||
: []),
|
||||
...(identity.instrumentUid?.trim() ?
|
||||
[{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] :
|
||||
[]),
|
||||
...(identity.figi?.trim() ?
|
||||
[{ identifierType: 'figi', identifier: identity.figi.trim() }] :
|
||||
[]),
|
||||
]);
|
||||
if (!identifiers.length)
|
||||
if (!identifiers.length) {
|
||||
return new Map<string, ReturnType<typeof selectMetadata>>();
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const lookups: MetadataLookupRow[] = [];
|
||||
for (const identifierType of ['uid', 'figi']) {
|
||||
@@ -59,7 +62,7 @@ export class InstrumentMetadataRepository {
|
||||
let offset = 0;
|
||||
offset < values.length;
|
||||
offset += metadataReadBatchSize
|
||||
)
|
||||
) {
|
||||
lookups.push(
|
||||
...(await tx.tbankInstrumentLookup.findMany({
|
||||
where: {
|
||||
@@ -71,6 +74,7 @@ export class InstrumentMetadataRepository {
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
const uids = [
|
||||
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
|
||||
@@ -80,7 +84,7 @@ export class InstrumentMetadataRepository {
|
||||
let offset = 0;
|
||||
offset < uids.length;
|
||||
offset += metadataReadBatchSize
|
||||
)
|
||||
) {
|
||||
instruments.push(
|
||||
...(await tx.tbankInstrument.findMany({
|
||||
where: {
|
||||
@@ -89,6 +93,7 @@ export class InstrumentMetadataRepository {
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
const byIdentifier = new Map(
|
||||
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
|
||||
);
|
||||
@@ -103,7 +108,7 @@ export class InstrumentMetadataRepository {
|
||||
}
|
||||
|
||||
async reserve(
|
||||
attemptOwner: { userId: string; revision: string },
|
||||
attemptOwner: { revision: string; userId: string },
|
||||
identity: MetadataIdentity,
|
||||
runId: string,
|
||||
active: ReadonlySet<string>,
|
||||
@@ -125,8 +130,9 @@ export class InstrumentMetadataRepository {
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === runId &&
|
||||
active.has(previous.attemptId)
|
||||
)
|
||||
) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
const data = {
|
||||
attemptId: randomUUID(),
|
||||
processRunId: runId,
|
||||
@@ -183,12 +189,13 @@ export class InstrumentMetadataRepository {
|
||||
['uid', uid],
|
||||
['figi', figi],
|
||||
] as const) {
|
||||
if (id && uid && !aliases.has(`${type}:${id}`))
|
||||
if (id && uid && !aliases.has(`${type}:${id}`)) {
|
||||
aliases.set(`${type}:${id}`, {
|
||||
type,
|
||||
id,
|
||||
data: { ...result.data, uid },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const result of aliases.values()) {
|
||||
@@ -196,8 +203,9 @@ export class InstrumentMetadataRepository {
|
||||
const uid =
|
||||
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
||||
// FIGI is an alias, never a fabricated provider UID.
|
||||
if (instrument && !uid)
|
||||
if (instrument && !uid) {
|
||||
throw new Error('Metadata response has no instrument UID');
|
||||
}
|
||||
if (instrument && uid) {
|
||||
const key = { provider, uid };
|
||||
const previous = await tx.tbankInstrument.findUnique({
|
||||
@@ -220,9 +228,9 @@ export class InstrumentMetadataRepository {
|
||||
update: {
|
||||
...data,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous?.metadata, payload)
|
||||
? 0
|
||||
: 1,
|
||||
increment: isDeepStrictEqual(previous?.metadata, payload) ?
|
||||
0 :
|
||||
1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -243,9 +251,9 @@ export class InstrumentMetadataRepository {
|
||||
matchedUids,
|
||||
fetchedAt: now,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
|
||||
? 0
|
||||
: 1,
|
||||
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ?
|
||||
0 :
|
||||
1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -266,7 +274,7 @@ export class InstrumentMetadataRepository {
|
||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
});
|
||||
if (success)
|
||||
if (success) {
|
||||
await this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
@@ -275,6 +283,7 @@ export class InstrumentMetadataRepository {
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +292,7 @@ function selectMetadata(
|
||||
lookups: ReadonlyMap<string, MetadataLookupRow>,
|
||||
instruments: ReadonlyMap<string, MetadataRow>,
|
||||
) {
|
||||
const resolve = (type: string, id: string | null | undefined) => {
|
||||
const resolve = (type: string, id: null | string | undefined) => {
|
||||
const lookup = lookups.get(`${type}:${id?.trim()}`);
|
||||
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
|
||||
const instrument = uid ? instruments.get(uid) : undefined;
|
||||
@@ -307,6 +316,7 @@ function selectMetadata(
|
||||
complete,
|
||||
fresh:
|
||||
complete &&
|
||||
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
|
||||
fetchedAt !== null &&
|
||||
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
|
||||
fetchedAt,
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import {
|
||||
InstrumentMetadataRepository,
|
||||
type MetadataIdentity,
|
||||
metadataKey,
|
||||
type MetadataLookup,
|
||||
} from './instrument-metadata.repository';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||
import {
|
||||
InstrumentMetadataRepository,
|
||||
metadataKey,
|
||||
type MetadataIdentity,
|
||||
type MetadataLookup,
|
||||
} from './instrument-metadata.repository';
|
||||
export { metadataKey } from './instrument-metadata.repository';
|
||||
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||
|
||||
type Producer = {
|
||||
identity: MetadataIdentity;
|
||||
attempt: TbankSyncAttempt;
|
||||
identity: MetadataIdentity;
|
||||
observers: TbankSyncAttempt[];
|
||||
};
|
||||
@Injectable()
|
||||
@@ -49,8 +50,9 @@ export class InstrumentMetadataService {
|
||||
const connection = await this.access.local(userId);
|
||||
if (revision && connection.revision !== revision) return null;
|
||||
const data = (await this.savedMany([identity])).get(metadataKey(identity));
|
||||
if (!observe && !data?.fresh)
|
||||
if (!observe && !data?.fresh) {
|
||||
await this.request(userId, connection.revision, identity);
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
work: await this.repository.work(userId, connection.revision, identity),
|
||||
@@ -119,7 +121,7 @@ export class InstrumentMetadataService {
|
||||
let useful = false;
|
||||
let uidFailed = false;
|
||||
if (identity.instrumentUid) {
|
||||
let data: TbankInstrument | null | undefined;
|
||||
let data: null | TbankInstrument | undefined;
|
||||
try {
|
||||
data = await this.tbank.getInstrumentByUid(
|
||||
token,
|
||||
@@ -130,29 +132,37 @@ export class InstrumentMetadataService {
|
||||
!identity.figi ||
|
||||
(error instanceof Error &&
|
||||
error.message === 'INVALID_TBANK_TOKEN')
|
||||
)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
uidFailed = true;
|
||||
}
|
||||
if (data?.uid && data.uid !== identity.instrumentUid)
|
||||
if (data?.uid && data.uid !== identity.instrumentUid) {
|
||||
throw new Error('Metadata UID mismatch');
|
||||
if (data !== undefined)
|
||||
}
|
||||
if (data !== undefined) {
|
||||
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||
}
|
||||
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
|
||||
if (!useful && data && !identity.figi)
|
||||
if (!useful && data && !identity.figi) {
|
||||
throw new Error('Incomplete metadata');
|
||||
}
|
||||
}
|
||||
if (!useful && identity.figi) {
|
||||
const data = await this.tbank.getInstrumentByFigi(
|
||||
token,
|
||||
identity.figi,
|
||||
);
|
||||
if (data?.figi && data.figi !== identity.figi)
|
||||
if (data?.figi && data.figi !== identity.figi) {
|
||||
throw new Error('Metadata FIGI mismatch');
|
||||
if (data && !data.name?.trim() && !data.ticker?.trim())
|
||||
}
|
||||
if (data && !data.name?.trim() && !data.ticker?.trim()) {
|
||||
throw new Error('Incomplete metadata');
|
||||
if (data === undefined || (uidFailed && !data))
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation at an untrusted upstream boundary includes missing payloads.
|
||||
if (data === undefined || (uidFailed && !data)) {
|
||||
throw new Error('Metadata source unavailable');
|
||||
}
|
||||
results.push({ type: 'figi', id: identity.figi, data });
|
||||
// A successful FIGI response identifies its canonical UID too;
|
||||
// a failed UID RPC is never persisted as a negative lookup.
|
||||
@@ -160,8 +170,9 @@ export class InstrumentMetadataService {
|
||||
uidFailed &&
|
||||
data?.uid === identity.instrumentUid &&
|
||||
identity.instrumentUid
|
||||
)
|
||||
) {
|
||||
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||
}
|
||||
}
|
||||
if (
|
||||
!results.length ||
|
||||
@@ -170,8 +181,9 @@ export class InstrumentMetadataService {
|
||||
(result) =>
|
||||
result.data?.name?.trim() || result.data?.ticker?.trim(),
|
||||
))
|
||||
)
|
||||
) {
|
||||
throw new Error('Incomplete metadata');
|
||||
}
|
||||
success = await this.repository.publish(
|
||||
attempt,
|
||||
results,
|
||||
@@ -179,12 +191,13 @@ export class InstrumentMetadataService {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
else
|
||||
} else {
|
||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Remove the producer before awaiting finalization: a new request cannot
|
||||
// attach to a producer whose observer list has already been drained.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
@@ -6,16 +5,19 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
import type { TbankAccount } from './tbank.client';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
export type TbankSyncAttempt = {
|
||||
userId: string;
|
||||
revision: string;
|
||||
id: string;
|
||||
attemptId: string;
|
||||
id: string;
|
||||
revision: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -48,8 +50,9 @@ export class TbankAccessService {
|
||||
connection.membershipAt,
|
||||
new Date(),
|
||||
)
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const scope = { userId, revision, scopeKey: 'membership' };
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
@@ -58,8 +61,9 @@ export class TbankAccessService {
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === processRunId &&
|
||||
active.has(previous.attemptId)
|
||||
)
|
||||
) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
const data = {
|
||||
attemptId: randomUUID(),
|
||||
processRunId,
|
||||
@@ -100,7 +104,7 @@ export class TbankAccessService {
|
||||
return result.count === 1;
|
||||
}
|
||||
|
||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> {
|
||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
||||
const work = await this.prisma.tbankSyncWork.findFirst({
|
||||
where: {
|
||||
...attempt,
|
||||
@@ -119,8 +123,9 @@ export class TbankAccessService {
|
||||
completeWork = false,
|
||||
): Promise<boolean> {
|
||||
const supported = supportedTbankAccounts(accounts);
|
||||
if (new Set(supported.map(({ id }) => id)).size !== supported.length)
|
||||
if (new Set(supported.map(({ id }) => id)).size !== supported.length) {
|
||||
throw new Error('Duplicate T-Bank account identity');
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Acquire the write fence before reading membership. Replacement and
|
||||
// attempt supersession cannot interleave with the publication below.
|
||||
@@ -146,6 +151,7 @@ export class TbankAccessService {
|
||||
const changed =
|
||||
previous.length !== next.length ||
|
||||
previous.some((account, index) => {
|
||||
// eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration.
|
||||
const incoming = next[index];
|
||||
return (
|
||||
account.accountId !== incoming.accountId ||
|
||||
@@ -181,11 +187,12 @@ export class TbankAccessService {
|
||||
membershipRevision: { increment: changed ? 1 : 0 },
|
||||
},
|
||||
});
|
||||
if (completeWork)
|
||||
if (completeWork) {
|
||||
await tx.tbankSyncWork.update({
|
||||
where: { id: attempt.id },
|
||||
data: { state: 'complete', progress: { accounts: supported.length } },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -224,17 +231,20 @@ export class TbankAccessService {
|
||||
accounts: { orderBy: { upstreamOrder: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!connection)
|
||||
if (!connection) {
|
||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||
if (connection.invalidToken)
|
||||
}
|
||||
if (connection.invalidToken) {
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
}
|
||||
if (
|
||||
connection.membershipAt &&
|
||||
accountId &&
|
||||
accountId !== 'all' &&
|
||||
!connection.accounts.some((account) => account.accountId === accountId)
|
||||
)
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ type AccountType = keyof typeof accountTypes;
|
||||
export type SupportedTbankAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
openedOn: null | string;
|
||||
type: (typeof accountTypes)[AccountType]['type'];
|
||||
openedOn: string | null;
|
||||
};
|
||||
|
||||
export function supportedTbankAccounts(
|
||||
@@ -39,7 +39,7 @@ export function supportedTbankAccounts(
|
||||
});
|
||||
}
|
||||
|
||||
function openedOn(seconds: string | number | undefined): string | null {
|
||||
function openedOn(seconds: number | string | undefined): null | string {
|
||||
if (seconds === undefined) return null;
|
||||
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
||||
if (Number.isNaN(date.valueOf())) return null;
|
||||
|
||||
@@ -6,23 +6,23 @@ import type {
|
||||
} from './tbank.client';
|
||||
|
||||
export type TbankShare = TbankInstrument & {
|
||||
uid?: string;
|
||||
figi?: string;
|
||||
apiTradeAvailableFlag?: boolean;
|
||||
assetUid?: string;
|
||||
classCode?: string;
|
||||
realExchange?: string;
|
||||
apiTradeAvailableFlag?: boolean;
|
||||
currency?: string;
|
||||
shareType?: string;
|
||||
instrumentKind?: string;
|
||||
sector?: string;
|
||||
lot?: number;
|
||||
figi?: string;
|
||||
first_1dayCandleDate?: TbankTimestamp;
|
||||
instrumentKind?: string;
|
||||
lot?: number;
|
||||
realExchange?: string;
|
||||
sector?: string;
|
||||
shareType?: string;
|
||||
uid?: string;
|
||||
};
|
||||
export type TbankCandle = {
|
||||
time?: TbankTimestamp;
|
||||
close?: TbankDecimal;
|
||||
isComplete?: boolean;
|
||||
time?: TbankTimestamp;
|
||||
};
|
||||
export type TbankLastPrice = {
|
||||
instrumentUid?: string;
|
||||
@@ -32,38 +32,38 @@ export type TbankLastPrice = {
|
||||
export type TbankFundamentals = {
|
||||
assetUid?: string;
|
||||
currency?: string;
|
||||
ebitdaTtm?: number;
|
||||
marketCapitalization?: number;
|
||||
netDebtToEbitda?: number;
|
||||
netIncomeTtm?: number;
|
||||
peRatioTtm?: number;
|
||||
priceToBookTtm?: number;
|
||||
revenueTtm?: number;
|
||||
netIncomeTtm?: number;
|
||||
ebitdaTtm?: number;
|
||||
roe?: number;
|
||||
totalDebtMrq?: number;
|
||||
netDebtToEbitda?: number;
|
||||
};
|
||||
|
||||
export type TbankBond = Omit<TbankShare, 'shareType'> & {
|
||||
nominal?: TbankMoney;
|
||||
initialNominal?: TbankMoney;
|
||||
aciValue?: TbankMoney;
|
||||
maturityDate?: TbankTimestamp;
|
||||
amortizationFlag?: boolean;
|
||||
bondType?: string;
|
||||
callDate?: TbankTimestamp;
|
||||
couponQuantityPerYear?: number;
|
||||
floatingCouponFlag?: boolean;
|
||||
initialNominal?: TbankMoney;
|
||||
maturityDate?: TbankTimestamp;
|
||||
nominal?: TbankMoney;
|
||||
perpetualFlag?: boolean;
|
||||
amortizationFlag?: boolean;
|
||||
subordinatedFlag?: boolean;
|
||||
bondType?: string;
|
||||
riskLevel?: string;
|
||||
subordinatedFlag?: boolean;
|
||||
};
|
||||
export type TbankCoupon = {
|
||||
couponNumber?: string;
|
||||
couponDate?: TbankTimestamp;
|
||||
couponEndDate?: TbankTimestamp;
|
||||
couponNumber?: string;
|
||||
couponPeriod?: number;
|
||||
couponStartDate?: TbankTimestamp;
|
||||
couponType?: string;
|
||||
fixDate?: TbankTimestamp;
|
||||
payOneBond?: TbankMoney;
|
||||
couponType?: string;
|
||||
couponStartDate?: TbankTimestamp;
|
||||
couponEndDate?: TbankTimestamp;
|
||||
couponPeriod?: number;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import {
|
||||
TbankAccessService,
|
||||
|
||||
@@ -8,6 +8,7 @@ describe('request-driven source freshness', () => {
|
||||
'%s expires at its boundary without changing source time',
|
||||
(source) => {
|
||||
const fetchedAt = new Date('2026-09-01Z');
|
||||
// eslint-disable-next-line security/detect-object-injection -- The key comes from the local source-policy test table.
|
||||
const ttl = tbankSourceTtlMs[source];
|
||||
expect(
|
||||
tbankSourceNeedsRefresh(
|
||||
|
||||
@@ -21,6 +21,7 @@ export function tbankSourceNeedsRefresh(
|
||||
return (
|
||||
force ||
|
||||
!fetchedAt ||
|
||||
// eslint-disable-next-line security/detect-object-injection -- The key is a member of the closed TbankSource union.
|
||||
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
@@ -25,8 +26,9 @@ export class TbankTokenService {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
export { metadataKey } from './instrument-metadata.service';
|
||||
export type { MetadataIdentity } from './instrument-metadata.service';
|
||||
export {
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
export { supportedTbankAccounts } from './tbank-account.policy';
|
||||
export {
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
@@ -12,8 +15,15 @@ export {
|
||||
type TbankLastPrice,
|
||||
type TbankShare,
|
||||
} from './tbank-market.types';
|
||||
|
||||
export { TbankMembershipService } from './tbank-membership.service';
|
||||
|
||||
export {
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
} from './tbank-source.policy';
|
||||
|
||||
export { TbankTokenService } from './tbank-token.service';
|
||||
export { supportedTbankAccounts } from './tbank-account.policy';
|
||||
export {
|
||||
TbankClient,
|
||||
type TbankDividend,
|
||||
@@ -25,13 +35,3 @@ export {
|
||||
type TbankPositions,
|
||||
type TbankTimestamp,
|
||||
} from './tbank.client';
|
||||
|
||||
export { TbankMembershipService } from './tbank-membership.service';
|
||||
|
||||
export {
|
||||
tbankSourceTtlMs,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from './tbank-source.policy';
|
||||
|
||||
export { metadataKey } from './instrument-metadata.service';
|
||||
export type { MetadataIdentity } from './instrument-metadata.service';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { status } from '@grpc/grpc-js';
|
||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
describe('TbankClient operations', () => {
|
||||
@@ -57,6 +58,7 @@ describe('TbankClient operations', () => {
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
const call = jest.fn().mockResolvedValue({});
|
||||
(client as unknown as { call: jest.Mock }).call = call;
|
||||
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
|
||||
await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
|
||||
expect(call).toHaveBeenCalledWith('token', undefined, method, {
|
||||
instrumentId: 'uid',
|
||||
@@ -111,11 +113,10 @@ describe('TbankClient operations', () => {
|
||||
ok: true,
|
||||
});
|
||||
const failed = invoke('token', {}, 'getPortfolio', {});
|
||||
const rejected = expect(failed).rejects.toEqual(
|
||||
expect.any(ServiceUnavailableException),
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
await rejected;
|
||||
await Promise.all([
|
||||
expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)),
|
||||
jest.advanceTimersByTimeAsync(1000),
|
||||
]);
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'debug',
|
||||
@@ -140,7 +141,7 @@ describe('TbankClient metadata retries', () => {
|
||||
const instrument = { name: 'Облигация', ticker: 'BOND' };
|
||||
let client: TbankClient;
|
||||
let requestRpc: jest.Mock;
|
||||
let queue: { size: number; run: jest.Mock };
|
||||
let queue: { run: jest.Mock; size: number };
|
||||
let logger: {
|
||||
event: jest.Mock<void, [string, string, Record<string, unknown>]>;
|
||||
};
|
||||
@@ -234,12 +235,10 @@ describe('TbankClient metadata retries', () => {
|
||||
|
||||
it('stops retrying a saturated queue after three admission attempts', async () => {
|
||||
queue.size = 50;
|
||||
const result = expect(
|
||||
client.getInstrumentByUid('token', 'uid'),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(6000);
|
||||
await result;
|
||||
await Promise.all([
|
||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||
jest.advanceTimersByTimeAsync(6000),
|
||||
]);
|
||||
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
expect(logger.event).toHaveBeenCalledTimes(3);
|
||||
@@ -253,12 +252,10 @@ describe('TbankClient metadata retries', () => {
|
||||
|
||||
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
||||
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
||||
const result = expect(
|
||||
client.getInstrumentByUid('token', 'uid'),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(6000);
|
||||
await result;
|
||||
await Promise.all([
|
||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||
jest.advanceTimersByTimeAsync(6000),
|
||||
]);
|
||||
|
||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
@@ -282,6 +279,7 @@ describe('TbankClient metadata retries', () => {
|
||||
async (method) => {
|
||||
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
||||
|
||||
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
|
||||
await expect(client[method]('token', 'instrument')).resolves.toBeNull();
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import {
|
||||
ChannelCredentials,
|
||||
Client,
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import { Environment } from '../../config/env.validation';
|
||||
|
||||
import type {
|
||||
TbankBond,
|
||||
@@ -29,6 +26,10 @@ import type {
|
||||
TbankShare,
|
||||
} from './tbank-market.types';
|
||||
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import { Environment } from '../../config/env.validation';
|
||||
|
||||
const RATE_WINDOW_MS = 1_000;
|
||||
const METADATA_MAX_ATTEMPTS = 3;
|
||||
const RPC_MAX_ATTEMPTS = 2;
|
||||
@@ -42,78 +43,78 @@ type Unary<Request, Response> = (
|
||||
request: Request,
|
||||
metadata: Metadata,
|
||||
options: { deadline: Date },
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
callback: (error: null | ServiceError, response: Response) => void,
|
||||
) => ClientUnaryCall;
|
||||
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
||||
export type TbankDecimal = { units?: string | number; nano?: string | number };
|
||||
export type TbankDecimal = { nano?: number | string; units?: number | string };
|
||||
export type TbankMoney = TbankDecimal & { currency?: string };
|
||||
export type TbankAccount = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
openedDate?: { seconds?: string | number };
|
||||
openedDate?: { seconds?: number | string };
|
||||
type: string;
|
||||
};
|
||||
export type TbankPortfolio = {
|
||||
positions?: {
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
ticker?: string;
|
||||
quantity?: TbankDecimal;
|
||||
currentPrice?: TbankMoney;
|
||||
averagePositionPrice?: TbankMoney;
|
||||
currentPrice?: TbankMoney;
|
||||
figi?: string;
|
||||
instrumentType?: string;
|
||||
instrumentUid?: string;
|
||||
quantity?: TbankDecimal;
|
||||
ticker?: string;
|
||||
}[];
|
||||
};
|
||||
export type TbankPositions = { money?: TbankMoney[] };
|
||||
export type TbankInstrument = {
|
||||
uid?: string;
|
||||
brand?: { logoName?: null | string };
|
||||
figi?: string;
|
||||
instrumentType?: string;
|
||||
ticker?: string;
|
||||
name?: string;
|
||||
isin?: string;
|
||||
brand?: { logoName?: string | null };
|
||||
name?: string;
|
||||
ticker?: string;
|
||||
uid?: string;
|
||||
};
|
||||
export type TbankDividend = {
|
||||
dividendNet?: TbankMoney;
|
||||
recordDate?: TbankTimestamp;
|
||||
paymentDate?: TbankTimestamp;
|
||||
lastBuyDate?: TbankTimestamp;
|
||||
dividendType?: string;
|
||||
lastBuyDate?: TbankTimestamp;
|
||||
paymentDate?: TbankTimestamp;
|
||||
recordDate?: TbankTimestamp;
|
||||
};
|
||||
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
||||
export type TbankTimestamp = { nanos?: number; seconds?: number | string };
|
||||
export type TbankOperationItem = {
|
||||
state?: string;
|
||||
accruedInt?: TbankMoney;
|
||||
commission?: TbankMoney;
|
||||
cursor?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
date?: TbankTimestamp;
|
||||
type?: string;
|
||||
description?: string;
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
id?: string;
|
||||
instrumentType?: string;
|
||||
ticker?: string;
|
||||
instrumentUid?: string;
|
||||
name?: string;
|
||||
payment?: TbankMoney;
|
||||
price?: TbankMoney;
|
||||
commission?: TbankMoney;
|
||||
accruedInt?: TbankMoney;
|
||||
quantity?: string | number;
|
||||
quantityDone?: string | number;
|
||||
quantity?: number | string;
|
||||
quantityDone?: number | string;
|
||||
state?: string;
|
||||
ticker?: string;
|
||||
type?: string;
|
||||
};
|
||||
export type TbankOperationsByCursorRequest = {
|
||||
accountId: string;
|
||||
instrumentId?: string;
|
||||
from?: TbankTimestamp;
|
||||
to?: TbankTimestamp;
|
||||
cursor?: string;
|
||||
from?: TbankTimestamp;
|
||||
instrumentId?: string;
|
||||
limit?: number;
|
||||
state: 'OPERATION_STATE_EXECUTED';
|
||||
to?: TbankTimestamp;
|
||||
};
|
||||
export type TbankOperationsByCursorResponse = {
|
||||
hasNext?: boolean;
|
||||
nextCursor?: string;
|
||||
items?: TbankOperationItem[];
|
||||
nextCursor?: string;
|
||||
};
|
||||
type TbankNamespace = {
|
||||
tinkoff: {
|
||||
@@ -197,12 +198,14 @@ export class TbankClient {
|
||||
loadPackageDefinition(definition) as unknown as TbankNamespace
|
||||
).tinkoff.public.invest.api.contract.v1;
|
||||
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
||||
const credentials = caPath
|
||||
? ChannelCredentials.createSsl(readFileSync(caPath))
|
||||
: ChannelCredentials.createSsl();
|
||||
const credentials = caPath ?
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
|
||||
ChannelCredentials.createSsl(readFileSync(caPath)) :
|
||||
ChannelCredentials.createSsl();
|
||||
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
||||
const create = (name: string) =>
|
||||
new (
|
||||
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
|
||||
services[name] as new (
|
||||
address: string,
|
||||
credentials: ChannelCredentials,
|
||||
@@ -268,14 +271,14 @@ export class TbankClient {
|
||||
getInstrumentByFigi(
|
||||
token: string,
|
||||
figi: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
): Promise<null | TbankInstrument> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
||||
}
|
||||
|
||||
getInstrumentByUid(
|
||||
token: string,
|
||||
instrumentUid: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
): Promise<null | TbankInstrument> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
||||
}
|
||||
|
||||
@@ -320,7 +323,8 @@ export class TbankClient {
|
||||
},
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
getBond(token: string, uid: string): Promise<TbankBond | null> {
|
||||
|
||||
getBond(token: string, uid: string): Promise<null | TbankBond> {
|
||||
return this.call<{ instrument?: TbankBond }>(
|
||||
token,
|
||||
this.sparseInstruments,
|
||||
@@ -331,6 +335,7 @@ export class TbankClient {
|
||||
},
|
||||
).then((response) => response.instrument ?? null);
|
||||
}
|
||||
|
||||
getBondCoupons(
|
||||
token: string,
|
||||
uid: string,
|
||||
@@ -349,7 +354,7 @@ export class TbankClient {
|
||||
).then((response) => response.events ?? []);
|
||||
}
|
||||
|
||||
getShare(token: string, uid: string): Promise<TbankShare | null> {
|
||||
getShare(token: string, uid: string): Promise<null | TbankShare> {
|
||||
return this.call<{ instrument?: TbankShare }>(
|
||||
token,
|
||||
this.instruments,
|
||||
@@ -361,7 +366,7 @@ export class TbankClient {
|
||||
).then((response) => response.instrument ?? null);
|
||||
}
|
||||
|
||||
getAssetDescription(token: string, assetUid: string): Promise<string | null> {
|
||||
getAssetDescription(token: string, assetUid: string): Promise<null | string> {
|
||||
return this.call<{ asset?: { description?: string } }>(
|
||||
token,
|
||||
this.instruments,
|
||||
@@ -375,7 +380,7 @@ export class TbankClient {
|
||||
getFundamentals(
|
||||
token: string,
|
||||
assetUid: string,
|
||||
): Promise<TbankFundamentals | null> {
|
||||
): Promise<null | TbankFundamentals> {
|
||||
return this.call<{ fundamentals?: TbankFundamentals[] }>(
|
||||
token,
|
||||
this.sparseInstruments,
|
||||
@@ -399,13 +404,13 @@ export class TbankClient {
|
||||
).then((response) => response.lastPrices ?? []);
|
||||
}
|
||||
|
||||
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> {
|
||||
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
|
||||
return this.getLastPrices(token, [uid]).then(
|
||||
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
getTradingStatus(token: string, uid: string): Promise<string | null> {
|
||||
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
||||
return this.call<{ tradingStatus?: string }>(
|
||||
token,
|
||||
this.marketData,
|
||||
@@ -442,9 +447,9 @@ export class TbankClient {
|
||||
|
||||
private async getInstrument(
|
||||
token: string,
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI',
|
||||
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
|
||||
id: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
): Promise<null | TbankInstrument> {
|
||||
try {
|
||||
const response = await this.call<{ instrument?: TbankInstrument }>(
|
||||
token,
|
||||
@@ -471,8 +476,9 @@ export class TbankClient {
|
||||
const startedAt = performance.now();
|
||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||
try {
|
||||
if (queueFull)
|
||||
if (queueFull) {
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
}
|
||||
const response = await this.queue.run(() =>
|
||||
this.request<Response>(token, client, method, request),
|
||||
);
|
||||
@@ -484,15 +490,15 @@ export class TbankClient {
|
||||
return response;
|
||||
} catch (error) {
|
||||
const grpcCode = (error as ServiceError).code;
|
||||
const failureContext = queueFull
|
||||
? { code: 'TBANK_UNAVAILABLE' }
|
||||
: { grpcCode };
|
||||
const retryable = queueFull
|
||||
? metadataCall
|
||||
: [
|
||||
status.UNAVAILABLE,
|
||||
const failureContext = queueFull ?
|
||||
{ code: 'TBANK_UNAVAILABLE' } :
|
||||
{ grpcCode };
|
||||
const retryable = queueFull ?
|
||||
metadataCall :
|
||||
[
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
status.UNAVAILABLE,
|
||||
].includes(grpcCode);
|
||||
if (retryable && attempts < maxAttempts) {
|
||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||
@@ -504,9 +510,9 @@ export class TbankClient {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(
|
||||
resolve,
|
||||
metadataCall
|
||||
? attempts * METADATA_RETRY_DELAY_MS
|
||||
: RPC_RETRY_DELAY_MS,
|
||||
metadataCall ?
|
||||
attempts * METADATA_RETRY_DELAY_MS :
|
||||
RPC_RETRY_DELAY_MS,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
@@ -518,15 +524,17 @@ export class TbankClient {
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
if (
|
||||
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode)
|
||||
)
|
||||
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
|
||||
) {
|
||||
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
||||
throw new Error('INVALID_TBANK_TOKEN');
|
||||
}
|
||||
if (
|
||||
grpcCode === status.NOT_FOUND &&
|
||||
(method === 'shareBy' || metadataCall)
|
||||
)
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
}
|
||||
}
|
||||
@@ -542,6 +550,7 @@ export class TbankClient {
|
||||
return new Promise((resolve, reject) => {
|
||||
const metadata = new Metadata();
|
||||
metadata.set('Authorization', `Bearer ${token}`);
|
||||
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
|
||||
client[method](
|
||||
request as never,
|
||||
metadata,
|
||||
|
||||
@@ -11,8 +11,9 @@ export class TbankCrypto {
|
||||
|
||||
static fromBase64(key: string): TbankCrypto {
|
||||
const decoded = Buffer.from(key, 'base64');
|
||||
if (decoded.length !== KEY_SIZE_BYTES)
|
||||
if (decoded.length !== KEY_SIZE_BYTES) {
|
||||
throw new Error('Invalid token encryption key');
|
||||
}
|
||||
return new TbankCrypto(decoded);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TbankMembershipService } from './tbank-membership.service';
|
||||
|
||||
import { InstrumentMetadataRepository } from './instrument-metadata.repository';
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankMembershipService } from './tbank-membership.service';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user