fix: harden auth and investment synchronization
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Resolve A01-A10 and C01-C07 from the backend audit. Preserve auth failures, forced candle reconciliation and forecast freshness. Validate MOEX responses and nested HTTP errors; remove unused legacy paths. Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability. Record the remaining macOS native SQLite abort in the audit.
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { MoexClient } from './moex.client';
|
||||
|
||||
const emptyEvents = {
|
||||
coupons: { columns: ['coupondate', 'value', 'faceunit'], data: [] },
|
||||
offers: { columns: ['offerdate'], data: [] },
|
||||
description: { columns: ['name', 'value'], data: [] },
|
||||
};
|
||||
|
||||
describe('MoexClient', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
||||
@@ -92,17 +98,174 @@ describe('MoexClient', () => {
|
||||
expect(result?.value ?? null).toBe(value === null ? null : '0');
|
||||
},
|
||||
);
|
||||
it('treats a successful response without the requested block as no events', async () => {
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
description: { columns: ['name', 'value'], data: [] },
|
||||
boards: { columns: ['secid'], data: [] },
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
it('accepts confirmed empty blocks', async () => {
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(() => Promise.resolve(Response.json(emptyEvents)));
|
||||
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves not-found semantics independently of malformed HTTP 200', async () => {
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve(new Response(null, { status: 404 })),
|
||||
);
|
||||
const client = new MoexClient();
|
||||
await expect(client.bondEvents('MISSING')).resolves.toEqual([]);
|
||||
await expect(
|
||||
client.bondYield('MISSING', 'ISIN', 'TQOB'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing block', { coupons: undefined }],
|
||||
['missing columns', { coupons: { data: [] } }],
|
||||
['invalid column', { coupons: { columns: [null], data: [] } }],
|
||||
['invalid row', { coupons: { columns: ['coupondate'], data: [null] } }],
|
||||
[
|
||||
'short row',
|
||||
{ coupons: { columns: ['coupondate', 'value'], data: [['2099-02-01']] } },
|
||||
],
|
||||
[
|
||||
'long row',
|
||||
{ coupons: { columns: ['coupondate'], data: [['2099-02-01', 2]] } },
|
||||
],
|
||||
[
|
||||
'empty cursor',
|
||||
{
|
||||
'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [] },
|
||||
},
|
||||
],
|
||||
[
|
||||
'missing cursor field',
|
||||
{ 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } },
|
||||
],
|
||||
[
|
||||
'null cursor index',
|
||||
{
|
||||
'coupons.cursor': {
|
||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||
data: [[null, 0, 1]],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'boolean cursor total',
|
||||
{
|
||||
'coupons.cursor': {
|
||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||
data: [[0, false, 1]],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'empty cursor total',
|
||||
{
|
||||
'coupons.cursor': {
|
||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||
data: [[0, '', 1]],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'short page',
|
||||
{
|
||||
'coupons.cursor': {
|
||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||
data: [[0, 1, 1]],
|
||||
},
|
||||
},
|
||||
],
|
||||
])(
|
||||
'rejects %s without returning a partial calendar',
|
||||
async (_name, invalid) => {
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve(Response.json({ ...emptyEvents, ...invalid })),
|
||||
);
|
||||
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
|
||||
response: { code: 'MOEX_UNAVAILABLE' },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, -1])(
|
||||
'rejects nonpositive page size %s before repeating a fetch',
|
||||
async (pageSize) => {
|
||||
const starts: string[] = [];
|
||||
jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
||||
const url = new URL(input instanceof Request ? input.url : input);
|
||||
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
|
||||
starts.push(url.searchParams.get('start') ?? '');
|
||||
if (starts.length > 1)
|
||||
return Promise.reject(new Error('Repeated page'));
|
||||
}
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
...emptyEvents,
|
||||
'coupons.cursor': {
|
||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||
data: [[0, 1, pageSize]],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
|
||||
response: { code: 'MOEX_UNAVAILABLE' },
|
||||
});
|
||||
expect(starts).toEqual(['0']);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'reads all cursor pages and rejects a repeated index: %s',
|
||||
async (repeatIndex) => {
|
||||
const starts: string[] = [];
|
||||
jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
||||
const url = new URL(input instanceof Request ? input.url : input);
|
||||
const start = Number(url.searchParams.get('start'));
|
||||
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
|
||||
starts.push(String(start));
|
||||
if (starts.length > 2)
|
||||
return Promise.reject(new Error('Repeated page'));
|
||||
}
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
...emptyEvents,
|
||||
coupons: {
|
||||
columns: ['coupondate', 'value', 'faceunit'],
|
||||
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
|
||||
},
|
||||
'coupons.cursor': {
|
||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||
data: [[repeatIndex ? 0 : start, 2, 1]],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
const result = new MoexClient().bondEvents('BOND');
|
||||
if (repeatIndex)
|
||||
await expect(result).rejects.toMatchObject({
|
||||
response: { code: 'MOEX_UNAVAILABLE' },
|
||||
});
|
||||
else
|
||||
await expect(result).resolves.toEqual([
|
||||
{
|
||||
type: 'coupon',
|
||||
date: '2099-02-01',
|
||||
value: '2.000000001',
|
||||
currency: 'RUB',
|
||||
},
|
||||
{
|
||||
type: 'coupon',
|
||||
date: '2099-02-02',
|
||||
value: '2.000000001',
|
||||
currency: 'RUB',
|
||||
},
|
||||
]);
|
||||
expect(starts).toEqual(['0', '1']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -123,22 +123,26 @@ export class MoexClient {
|
||||
rows.push(...current);
|
||||
if (!paginated) return rows;
|
||||
const cursorBlock = response[`${block}.cursor`];
|
||||
if (!cursorBlock) {
|
||||
if (cursorBlock === undefined) {
|
||||
if (current.length < FALLBACK_PAGE_SIZE) return rows;
|
||||
start += FALLBACK_PAGE_SIZE;
|
||||
continue;
|
||||
}
|
||||
const cursor = parseRows(cursorBlock)[0];
|
||||
const cursors = parseRows(cursorBlock);
|
||||
const cursor = cursors[0];
|
||||
const index = integer(cursor?.index);
|
||||
const total = integer(cursor?.total);
|
||||
const pageSize = integer(cursor?.pagesize);
|
||||
if (
|
||||
index === null ||
|
||||
cursors.length !== 1 ||
|
||||
index !== start ||
|
||||
total === null ||
|
||||
pageSize === null ||
|
||||
index + pageSize >= total
|
||||
pageSize === 0 ||
|
||||
current.length !== Math.min(pageSize, total - start)
|
||||
)
|
||||
return rows;
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
if (index + pageSize >= total) return rows;
|
||||
start = index + pageSize;
|
||||
}
|
||||
}
|
||||
@@ -161,14 +165,13 @@ export class MoexClient {
|
||||
}
|
||||
|
||||
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
||||
if (!block) return [];
|
||||
if (!Array.isArray(block.columns) || !Array.isArray(block.data))
|
||||
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data))
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
const columns = block.columns;
|
||||
if (!columns.every((column) => typeof column === 'string'))
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
return block.data.map((values) => {
|
||||
if (!Array.isArray(values))
|
||||
if (!Array.isArray(values) || values.length !== columns.length)
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
return Object.fromEntries(
|
||||
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
||||
@@ -191,6 +194,6 @@ function currencyText(value: unknown): string | null {
|
||||
}
|
||||
|
||||
function integer(value: unknown): number | null {
|
||||
const result = Number(value);
|
||||
const result = Number(numberText(value) ?? NaN);
|
||||
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
jest.mock('../../prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
import type { TbankAccountContext } from './tbank-access.service';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
describe('TbankAccessService', () => {
|
||||
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
||||
const token = {
|
||||
decrypt: jest.fn(),
|
||||
translateInvalidToken: jest.fn((operation: () => Promise<unknown>) =>
|
||||
new TbankTokenService({
|
||||
getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
|
||||
} as never).translateInvalidToken(operation),
|
||||
),
|
||||
};
|
||||
const tbank = { getOpenAccounts: jest.fn() };
|
||||
const service = new TbankAccessService(
|
||||
prisma as never,
|
||||
token as never,
|
||||
tbank as never,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||
encryptedToken: 'encrypted',
|
||||
});
|
||||
token.decrypt.mockReturnValue('plain-token');
|
||||
tbank.getOpenAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 'account',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
name: ' Основной ',
|
||||
openedDate: { seconds: '1723507200' },
|
||||
},
|
||||
{ id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists supported accounts through the saved decrypted token', async () => {
|
||||
await expect(service.listSupportedAccounts('user')).resolves.toEqual([
|
||||
{
|
||||
id: 'account',
|
||||
type: 'BROKERAGE',
|
||||
name: 'Основной',
|
||||
openedOn: '2024-08-13',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({
|
||||
where: { userId: 'user' },
|
||||
});
|
||||
expect(token.decrypt).toHaveBeenCalledWith('encrypted');
|
||||
expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token');
|
||||
});
|
||||
|
||||
it('rejects missing connections before calling T-Bank', async () => {
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
|
||||
expect.any(ConflictException),
|
||||
);
|
||||
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the persisted connection generation without an upstream account call', async () => {
|
||||
const context = await service.withConnection('user', (value) => value);
|
||||
|
||||
expect(context).toEqual({
|
||||
token: 'plain-token',
|
||||
generation: expect.any(String) as string,
|
||||
});
|
||||
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('translates invalid tokens and preserves unavailable upstream errors', async () => {
|
||||
tbank.getOpenAccounts.mockRejectedValueOnce(
|
||||
new Error('INVALID_TBANK_TOKEN'),
|
||||
);
|
||||
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
|
||||
expect.any(BadRequestException),
|
||||
);
|
||||
|
||||
const unavailable = new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
tbank.getOpenAccounts.mockRejectedValueOnce(unavailable);
|
||||
await expect(service.listSupportedAccounts('user')).rejects.toBe(
|
||||
unavailable,
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps account-scoped handlers with resolved account access', async () => {
|
||||
const handler = jest.fn((context: TbankAccountContext) =>
|
||||
Promise.resolve({
|
||||
resolvedToken: context.token,
|
||||
generation: context.generation,
|
||||
accountId: context.account.id,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.withAccount('user', 'account', handler),
|
||||
).resolves.toEqual({
|
||||
resolvedToken: 'plain-token',
|
||||
generation: expect.any(String) as string,
|
||||
accountId: 'account',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives a stable opaque generation from each persisted encrypted payload', async () => {
|
||||
const generation = () =>
|
||||
service.withAccount('user', 'account', ({ generation: value }) =>
|
||||
Promise.resolve(value),
|
||||
);
|
||||
prisma.tbankConnection.findUnique
|
||||
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
|
||||
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
|
||||
.mockResolvedValueOnce({ encryptedToken: 'encrypted:second-nonce' });
|
||||
|
||||
const first = await generation();
|
||||
const sameRecord = await generation();
|
||||
const replaced = await generation();
|
||||
|
||||
expect(sameRecord).toBe(first);
|
||||
expect(replaced).not.toBe(first);
|
||||
expect(first).not.toContain('plain-token');
|
||||
expect(first).not.toContain('encrypted:first-nonce');
|
||||
});
|
||||
|
||||
it('rejects inaccessible accounts before running the handler', async () => {
|
||||
const handler = jest.fn();
|
||||
|
||||
await expect(
|
||||
service.withAccount('user', 'missing', handler),
|
||||
).rejects.toEqual(expect.any(NotFoundException));
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('translates invalid-token handler failures and preserves unrelated handler failures', async () => {
|
||||
await expect(
|
||||
service.withAccount('user', 'account', () =>
|
||||
Promise.reject(new Error('INVALID_TBANK_TOKEN')),
|
||||
),
|
||||
).rejects.toEqual(expect.any(BadRequestException));
|
||||
|
||||
const failure = new Error('source failed');
|
||||
await expect(
|
||||
service.withAccount('user', 'account', () => Promise.reject(failure)),
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
@@ -5,24 +5,11 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
SupportedTbankAccount,
|
||||
accessibleTbankAccount,
|
||||
supportedTbankAccounts,
|
||||
} from './tbank-account.policy';
|
||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
import { TbankAccount, TbankClient } from './tbank.client';
|
||||
|
||||
export type TbankConnectionContext = {
|
||||
token: string;
|
||||
generation: string;
|
||||
};
|
||||
|
||||
export type TbankAccountContext = TbankConnectionContext & {
|
||||
account: TbankAccount;
|
||||
};
|
||||
import type { TbankAccount } from './tbank.client';
|
||||
|
||||
export type TbankSyncAttempt = {
|
||||
userId: string;
|
||||
@@ -36,7 +23,6 @@ export class TbankAccessService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly tokens: TbankTokenService,
|
||||
private readonly tbank: TbankClient,
|
||||
) {}
|
||||
|
||||
async reserveMembership(
|
||||
@@ -251,81 +237,4 @@ export class TbankAccessService {
|
||||
throw new NotFoundException();
|
||||
return connection;
|
||||
}
|
||||
|
||||
async listSupportedAccounts(
|
||||
userId: string,
|
||||
): Promise<SupportedTbankAccount[]> {
|
||||
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
|
||||
}
|
||||
|
||||
async withSupportedAccounts<T>(
|
||||
userId: string,
|
||||
handler: (context: {
|
||||
token: string;
|
||||
generation: string;
|
||||
accounts: SupportedTbankAccount[];
|
||||
}) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
return this.withConnection(userId, async ({ token, generation }) =>
|
||||
handler({
|
||||
token,
|
||||
generation,
|
||||
accounts: supportedTbankAccounts(
|
||||
await this.tbank.getOpenAccounts(token),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async withConnection<T>(
|
||||
userId: string,
|
||||
handler: (context: TbankConnectionContext) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const connection = await this.connection(userId);
|
||||
return this.tokens.translateInvalidToken(async () => handler(connection));
|
||||
}
|
||||
|
||||
async withAccount<T>(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
handler: (context: TbankAccountContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.withConnection(userId, (connection) =>
|
||||
this.withAccountForConnection(connection, accountId, handler),
|
||||
);
|
||||
}
|
||||
|
||||
async withAccountForConnection<T>(
|
||||
connection: TbankConnectionContext,
|
||||
accountId: string,
|
||||
handler: (context: TbankAccountContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { token, generation } = connection;
|
||||
return this.tokens.translateInvalidToken(async () => {
|
||||
const account = accessibleTbankAccount(
|
||||
await this.tbank.getOpenAccounts(token),
|
||||
accountId,
|
||||
);
|
||||
if (!account) throw new NotFoundException();
|
||||
return handler({ token, generation, account });
|
||||
});
|
||||
}
|
||||
|
||||
private async connection(
|
||||
userId: string,
|
||||
): Promise<{ token: string; generation: string }> {
|
||||
const connection = await this.prisma.tbankConnection.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
if (!connection)
|
||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||
if (connection.invalidToken)
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
return {
|
||||
token: this.tokens.decrypt(connection.encryptedToken),
|
||||
generation: createHash('sha256')
|
||||
.update(connection.encryptedToken)
|
||||
.digest('base64url'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
accessibleTbankAccount,
|
||||
supportedTbankAccounts,
|
||||
} from './tbank-account.policy';
|
||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||
|
||||
describe('tbank-account-policy', () => {
|
||||
it('normalizes supported account types and fallback names', () => {
|
||||
@@ -45,26 +42,4 @@ describe('tbank-account-policy', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches account-scoped access only for supported account types', () => {
|
||||
expect(
|
||||
accessibleTbankAccount(
|
||||
[
|
||||
{ id: 'account', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
|
||||
{ id: 'other', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Other' },
|
||||
],
|
||||
'account',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
accessibleTbankAccount(
|
||||
[{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Brokerage' }],
|
||||
'account',
|
||||
),
|
||||
).toEqual({
|
||||
id: 'account',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
name: 'Brokerage',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,17 +39,6 @@ export function supportedTbankAccounts(
|
||||
});
|
||||
}
|
||||
|
||||
export function accessibleTbankAccount(
|
||||
accounts: TbankAccount[],
|
||||
accountId: string,
|
||||
): TbankAccount | null {
|
||||
return (
|
||||
accounts.find(
|
||||
(account) => account.id === accountId && account.type in accountTypes,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function openedOn(seconds: string | number | undefined): string | null {
|
||||
if (seconds === undefined) return null;
|
||||
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
export { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
export {
|
||||
TbankAccessService,
|
||||
type TbankConnectionContext,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
export {
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
type TbankCoupon,
|
||||
type TbankFundamentals,
|
||||
type TbankLastPrice,
|
||||
type TbankShare,
|
||||
} from './tbank-market.types';
|
||||
export { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
Reference in New Issue
Block a user