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:
@@ -15,6 +15,12 @@ export class ProblemDetailsDto {
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['pointer', 'detail'],
|
||||
properties: { pointer: { type: 'string' }, detail: { type: 'string' } },
|
||||
},
|
||||
example: [{ pointer: '#/email', detail: 'must be a valid email address' }],
|
||||
})
|
||||
errors?: { pointer: string; detail: string }[];
|
||||
|
||||
@@ -14,7 +14,10 @@ describe('HttpExceptionFilter', () => {
|
||||
}),
|
||||
} as unknown as ArgumentsHost;
|
||||
|
||||
filter.catch(new Error('database password: secret'), host);
|
||||
filter.catch(
|
||||
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
|
||||
host,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
expect(type).toHaveBeenCalledWith('application/problem+json');
|
||||
@@ -23,7 +26,6 @@ describe('HttpExceptionFilter', () => {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
});
|
||||
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
|
||||
expect(typeof locals.errorStack).toBe('string');
|
||||
expect(locals.errorStack).not.toContain('database password: secret');
|
||||
expect(locals.errorStack).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,14 +27,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
? 'VALIDATION_ERROR'
|
||||
: this.codeFor(status, payload);
|
||||
(response.locals ??= {}).publicErrorCode = code;
|
||||
if (
|
||||
status >= Number(HttpStatus.INTERNAL_SERVER_ERROR) &&
|
||||
exception instanceof Error
|
||||
)
|
||||
response.locals.errorStack = exception.stack
|
||||
?.split('\n')
|
||||
.slice(1)
|
||||
.join('\n');
|
||||
response
|
||||
.status(status)
|
||||
.type('application/problem+json')
|
||||
@@ -69,12 +61,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
|
||||
private validationErrors(
|
||||
errors: ValidationError[],
|
||||
prefix = '#',
|
||||
): { pointer: string; detail: string }[] {
|
||||
return errors.flatMap((error) =>
|
||||
Object.values(error.constraints ?? {}).map((detail) => ({
|
||||
pointer: `#/${error.property}`,
|
||||
detail,
|
||||
})),
|
||||
);
|
||||
return errors.flatMap((error) => {
|
||||
if (!error || typeof error.property !== 'string') return [];
|
||||
const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
|
||||
return [
|
||||
...Object.values(error.constraints ?? {}).map((detail) => ({
|
||||
pointer,
|
||||
detail,
|
||||
})),
|
||||
...this.validationErrors(error.children ?? [], pointer),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,6 @@ export class RequestLoggingMiddleware implements NestMiddleware {
|
||||
...(response.locals.publicErrorCode
|
||||
? { code: response.locals.publicErrorCode as string }
|
||||
: {}),
|
||||
...(response.locals.errorStack
|
||||
? { stack: response.locals.errorStack as string }
|
||||
: {}),
|
||||
};
|
||||
const level =
|
||||
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||
|
||||
@@ -17,7 +17,6 @@ const allowedFields = new Set([
|
||||
'status',
|
||||
'durationMs',
|
||||
'code',
|
||||
'stack',
|
||||
'operation',
|
||||
'grpcCode',
|
||||
'attempts',
|
||||
|
||||
@@ -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';
|
||||
|
||||
+13
-2
@@ -1,4 +1,8 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
INestApplication,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -26,7 +30,14 @@ export function configureApp(app: INestApplication): void {
|
||||
app.use(cookieParser());
|
||||
const requestLogging = app.get(RequestLoggingMiddleware);
|
||||
app.use(requestLogging.use.bind(requestLogging));
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
validationError: { target: false, value: false },
|
||||
exceptionFactory: (message) => new BadRequestException({ message }),
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.enableShutdownHooks();
|
||||
}
|
||||
|
||||
@@ -29,9 +29,8 @@ export class AuthService {
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
where: { email },
|
||||
});
|
||||
if (existing)
|
||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||
@@ -41,7 +40,7 @@ export class AuthService {
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
email: normalizedEmail,
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||
},
|
||||
});
|
||||
@@ -58,7 +57,7 @@ export class AuthService {
|
||||
password: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: this.normalizeEmail(email) },
|
||||
where: { email },
|
||||
});
|
||||
const valid = await bcrypt.compare(
|
||||
password,
|
||||
@@ -103,26 +102,27 @@ export class AuthService {
|
||||
|
||||
async logout(token?: string): Promise<void> {
|
||||
if (!token) return;
|
||||
let payload: RefreshPayload;
|
||||
try {
|
||||
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
if (payload.type !== 'refresh') return;
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
});
|
||||
if (
|
||||
session &&
|
||||
!session.revokedAt &&
|
||||
session.refreshTokenHash === this.hash(token)
|
||||
) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Logout is intentionally idempotent.
|
||||
return;
|
||||
}
|
||||
if (payload.type !== 'refresh') return;
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
});
|
||||
if (
|
||||
session &&
|
||||
!session.revokedAt &&
|
||||
session.refreshTokenHash === this.hash(token)
|
||||
) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,10 +187,6 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeEmail(email: string): string {
|
||||
return email;
|
||||
}
|
||||
|
||||
private hash(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
|
||||
|
||||
jest.mock('../application/auth.service', () => ({
|
||||
AuthService: class AuthService {},
|
||||
}));
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
describe('AuthController response statuses', () => {
|
||||
it.each(['register', 'login', 'refresh'] as const)(
|
||||
'%s explicitly returns HTTP 200',
|
||||
(handler) => {
|
||||
// Nest stores @HttpCode metadata on the unbound handler function.
|
||||
const handlerTarget = Object.getOwnPropertyDescriptor(
|
||||
AuthController.prototype,
|
||||
handler,
|
||||
)?.value as object;
|
||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handlerTarget)).toBe(200);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -87,7 +87,7 @@ export class AuthController {
|
||||
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
return this.respond(response, await this.auth.refresh(token));
|
||||
} catch (error) {
|
||||
this.clearCookie(response);
|
||||
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,9 @@ describe('bank card calculations', () => {
|
||||
amountKopecks: 5000n,
|
||||
},
|
||||
];
|
||||
expect(bankSummary('2026-09', operations)).toEqual({
|
||||
expect(
|
||||
bankSummary({ from: '2026-09-01', to: '2026-09-30' }, operations),
|
||||
).toEqual({
|
||||
expenses: 120n,
|
||||
refunds: 500n,
|
||||
net: -380n,
|
||||
@@ -125,7 +127,9 @@ describe('bank card calculations', () => {
|
||||
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
|
||||
],
|
||||
});
|
||||
expect(bankSummary('2026-07', operations)).toEqual({
|
||||
expect(
|
||||
bankSummary({ from: '2026-07-01', to: '2026-07-31' }, operations),
|
||||
).toEqual({
|
||||
expenses: 0n,
|
||||
refunds: 0n,
|
||||
net: 0n,
|
||||
@@ -141,7 +145,12 @@ describe('bank card calculations', () => {
|
||||
amountKopecks: 2n ** 63n - 1n,
|
||||
};
|
||||
const total = 2n * (2n ** 63n - 1n);
|
||||
expect(bankSummary('2026-09', [operation, operation])).toEqual({
|
||||
expect(
|
||||
bankSummary({ from: '2026-09-01', to: '2026-09-30' }, [
|
||||
operation,
|
||||
operation,
|
||||
]),
|
||||
).toEqual({
|
||||
expenses: total,
|
||||
refunds: 0n,
|
||||
net: total,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BankOperationKind } from './bank-operation-kind';
|
||||
|
||||
const ISO_MONTH_LENGTH = 7;
|
||||
export function moscowToday(now = new Date()): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Moscow',
|
||||
@@ -54,7 +53,7 @@ export function bankBalance(
|
||||
}
|
||||
|
||||
export function bankSummary(
|
||||
period: string | { from: string; to: string },
|
||||
period: { from: string; to: string },
|
||||
operations: Array<{
|
||||
kind: string;
|
||||
date: string;
|
||||
@@ -70,12 +69,7 @@ export function bankSummary(
|
||||
>();
|
||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||
for (const operation of operations) {
|
||||
if (
|
||||
typeof period === 'string'
|
||||
? operation.date.slice(0, ISO_MONTH_LENGTH) !== period
|
||||
: operation.date < period.from || operation.date > period.to
|
||||
)
|
||||
continue;
|
||||
if (operation.date < period.from || operation.date > period.to) continue;
|
||||
if (
|
||||
operation.kind !== BankOperationKind.Expense &&
|
||||
operation.kind !== BankOperationKind.Refund
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
jest.mock('../infrastructure/candle.repository', () => ({
|
||||
CandleRepository: class {},
|
||||
}));
|
||||
jest.mock('./catalog-enrichment.service', () => ({
|
||||
CatalogEnrichmentService: class {},
|
||||
}));
|
||||
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
|
||||
TbankAccessService: class {},
|
||||
TbankClient: class {},
|
||||
}));
|
||||
|
||||
import { CandleService } from './candle.service';
|
||||
|
||||
describe('CandleService forced demand', () => {
|
||||
it.each([false, true])(
|
||||
'reconciles the full range through the latest forced timestamp while force=%s is loading',
|
||||
async (initialForce) => {
|
||||
let entered = () => {};
|
||||
let release = () => {};
|
||||
let complete = () => {};
|
||||
const started = new Promise<void>((resolve) => (entered = resolve));
|
||||
const blocked = new Promise<void>((resolve) => (release = resolve));
|
||||
const finished = new Promise<void>((resolve) => (complete = resolve));
|
||||
let completed = 0;
|
||||
const work = { id: 'work', attemptId: 'attempt' };
|
||||
const repository = {
|
||||
reserve: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ work, launch: true })
|
||||
.mockResolvedValueOnce({
|
||||
work: { id: 'other', attemptId: 'other-attempt' },
|
||||
launch: true,
|
||||
})
|
||||
.mockResolvedValue({ work, launch: false }),
|
||||
read: jest.fn().mockResolvedValue({
|
||||
coverage: [
|
||||
{
|
||||
from: new Date('2026-01-01Z'),
|
||||
to: new Date('2026-02-01Z'),
|
||||
isComplete: true,
|
||||
fetchedAt: new Date('2026-02-01Z'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
stage: jest.fn().mockResolvedValue(true),
|
||||
publish: jest.fn((_attempt, _intent, done = true) => {
|
||||
if (done && ++completed === 2) complete();
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
fail: jest.fn(),
|
||||
};
|
||||
const sources = {
|
||||
ensureDetail: jest.fn().mockResolvedValue({
|
||||
first_1dayCandleDate: {
|
||||
seconds: String(Date.parse('2026-01-01Z') / 1000),
|
||||
},
|
||||
}),
|
||||
};
|
||||
const upstream = { getCandles: jest.fn().mockResolvedValue([]) };
|
||||
upstream.getCandles.mockImplementationOnce(async () => {
|
||||
entered();
|
||||
await blocked;
|
||||
return [];
|
||||
});
|
||||
const service = new CandleService(
|
||||
repository as never,
|
||||
sources as never,
|
||||
{ tokenForAttempt: jest.fn().mockResolvedValue('token') } as never,
|
||||
upstream as never,
|
||||
{ event: jest.fn() } as never,
|
||||
);
|
||||
const input = {
|
||||
uid: 'uid',
|
||||
instrumentType: 'share' as const,
|
||||
period: 'all' as const,
|
||||
};
|
||||
await service.request(
|
||||
'owner',
|
||||
'revision',
|
||||
input,
|
||||
new Date('2026-02-02T12:00:00Z'),
|
||||
initialForce,
|
||||
2,
|
||||
);
|
||||
await started;
|
||||
const latest = new Date('2026-02-02T13:00:00Z');
|
||||
await service.request(
|
||||
'owner',
|
||||
'revision',
|
||||
{ ...input, uid: 'other' },
|
||||
latest,
|
||||
false,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
await service.request('owner', 'revision', input, latest, true, 0),
|
||||
).toBe('work');
|
||||
release();
|
||||
await finished;
|
||||
|
||||
expect(upstream.getCandles).toHaveBeenCalledWith(
|
||||
'token',
|
||||
'uid',
|
||||
new Date('2026-01-01Z'),
|
||||
latest,
|
||||
);
|
||||
expect(repository.fail).not.toHaveBeenCalled();
|
||||
expect(
|
||||
upstream.getCandles.mock.calls.map((call: unknown[]) => call[1]),
|
||||
).toEqual(['uid', 'uid', 'other']);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
type TbankShare,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
@@ -39,8 +39,12 @@ type Producer = {
|
||||
force: boolean;
|
||||
priority: number;
|
||||
observers: TbankSyncAttempt[];
|
||||
ranges?: CandleRange[];
|
||||
nextWindow?: number;
|
||||
pass?: {
|
||||
now: Date;
|
||||
force: boolean;
|
||||
ranges: CandleRange[];
|
||||
nextWindow: number;
|
||||
};
|
||||
};
|
||||
const months = { month: 1, halfYear: 6, year: 12 };
|
||||
const tailFreshForMs = 60_000;
|
||||
@@ -323,9 +327,14 @@ export class CandleService {
|
||||
id: reserved.work.id,
|
||||
attemptId: reserved.work.attemptId,
|
||||
};
|
||||
const producer = this.producers.get(scope(input));
|
||||
if (producer) {
|
||||
producer.force ||= force;
|
||||
producer.priority = Math.min(producer.priority, priority);
|
||||
if (force && now > producer.now) producer.now = now;
|
||||
}
|
||||
if (reserved.launch) {
|
||||
this.active.add(attempt.attemptId);
|
||||
const producer = this.producers.get(scope(input));
|
||||
if (producer) producer.observers.push(attempt);
|
||||
else {
|
||||
const next = { input, attempt, now, force, priority, observers: [] };
|
||||
@@ -346,7 +355,7 @@ export class CandleService {
|
||||
}
|
||||
|
||||
private async load(producer: Producer) {
|
||||
const { input, attempt, now } = producer;
|
||||
const { input, attempt } = producer;
|
||||
const instrument = await this.sources.ensureDetail(
|
||||
attempt.userId,
|
||||
attempt.revision,
|
||||
@@ -354,20 +363,24 @@ export class CandleService {
|
||||
input.uid,
|
||||
);
|
||||
if (!instrument) throw new NotFoundException();
|
||||
const range = rangeFor(input, instrument, now);
|
||||
if (!producer.ranges) {
|
||||
let pass = producer.pass;
|
||||
if (!pass || (producer.force && (!pass.force || producer.now > pass.now))) {
|
||||
const { now, force } = producer;
|
||||
const range = rangeFor(input, instrument, now);
|
||||
const saved = await this.repository.read(input.uid, range);
|
||||
producer.ranges = (
|
||||
producer.force ? [range] : candleGaps(range, saved.coverage, now)
|
||||
)
|
||||
.flatMap(candleWindows)
|
||||
.reverse();
|
||||
producer.nextWindow = 0;
|
||||
pass = producer.pass = {
|
||||
now,
|
||||
force,
|
||||
ranges: (force ? [range] : candleGaps(range, saved.coverage, now))
|
||||
.flatMap(candleWindows)
|
||||
.reverse(),
|
||||
nextWindow: 0,
|
||||
};
|
||||
}
|
||||
const ranges = producer.ranges;
|
||||
const index = producer.nextWindow ?? 0;
|
||||
const { ranges, now, force } = pass;
|
||||
const index = pass.nextWindow;
|
||||
let window = ranges[index];
|
||||
if (window && !producer.force) {
|
||||
if (window && !force) {
|
||||
const latest = await this.repository.read(input.uid, window);
|
||||
const gaps = candleGaps(window, latest.coverage, now)
|
||||
.flatMap(candleWindows)
|
||||
@@ -398,7 +411,7 @@ export class CandleService {
|
||||
if (!(await this.repository.stage(attempt, window, rows, now)))
|
||||
return false;
|
||||
if (
|
||||
!producer.force &&
|
||||
!force &&
|
||||
!(await this.repository.publish(
|
||||
attempt,
|
||||
{ uid: input.uid, ranges: [window] },
|
||||
@@ -406,13 +419,10 @@ export class CandleService {
|
||||
))
|
||||
)
|
||||
return false;
|
||||
producer.nextWindow = index + 1;
|
||||
if (producer.nextWindow < ranges.length) return null;
|
||||
pass.nextWindow = index + 1;
|
||||
if (pass.nextWindow < ranges.length) return null;
|
||||
}
|
||||
return this.repository.publish(attempt, {
|
||||
uid: input.uid,
|
||||
ranges: producer.force ? ranges : [],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async drain() {
|
||||
@@ -443,20 +453,43 @@ export class CandleService {
|
||||
code = response.code;
|
||||
}
|
||||
}
|
||||
if (!success) await this.repository.fail(producer.attempt, code);
|
||||
this.active.delete(producer.attempt.attemptId);
|
||||
for (const observer of producer.observers) {
|
||||
if (
|
||||
!success ||
|
||||
!(await this.repository.completeObserver(
|
||||
observer,
|
||||
producer.attempt,
|
||||
))
|
||||
)
|
||||
await this.repository.fail(observer, code);
|
||||
this.active.delete(observer.attemptId);
|
||||
// Seal publication with reservations so a late forced join cannot be
|
||||
// acknowledged after the final demand check but before work completion.
|
||||
const finished = this.reservations.then(async () => {
|
||||
const pass = producer.pass;
|
||||
if (success && pass) {
|
||||
if (producer.force && (!pass.force || producer.now > pass.now))
|
||||
return false;
|
||||
try {
|
||||
success = await this.repository.publish(producer.attempt, {
|
||||
uid: producer.input.uid,
|
||||
ranges: pass.force ? pass.ranges : [],
|
||||
});
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
if (!success) await this.repository.fail(producer.attempt, code);
|
||||
this.active.delete(producer.attempt.attemptId);
|
||||
for (const observer of producer.observers) {
|
||||
if (
|
||||
!success ||
|
||||
!(await this.repository.completeObserver(
|
||||
observer,
|
||||
producer.attempt,
|
||||
))
|
||||
)
|
||||
await this.repository.fail(observer, code);
|
||||
this.active.delete(observer.attemptId);
|
||||
}
|
||||
this.producers.delete(scope(producer.input));
|
||||
return true;
|
||||
});
|
||||
this.reservations = finished.catch(() => undefined);
|
||||
if (!(await finished)) {
|
||||
this.queue.push(producer);
|
||||
continue;
|
||||
}
|
||||
this.producers.delete(scope(producer.input));
|
||||
if (success && !producer.force && producer.input.period !== 'all')
|
||||
await this.request(
|
||||
producer.attempt.userId,
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
TbankClient,
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { TbankDividend } from '../../../../integrations/tbank/tbank.api';
|
||||
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||
@@ -14,11 +13,6 @@ import {
|
||||
} from '../../../../common/time';
|
||||
import { MoexClient } from '../../../../integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||
import type {
|
||||
TbankCandle,
|
||||
TbankFundamentals,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types';
|
||||
import { previousClose, timestamp } from './instrument-market';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -29,10 +23,14 @@ import {
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
type TbankCoupon,
|
||||
type TbankDividend,
|
||||
type TbankFundamentals,
|
||||
type TbankLastPrice,
|
||||
type TbankShare,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankLastPrice } from '../../../../integrations/tbank/tbank-market.types';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import type {
|
||||
TbankCandle,
|
||||
TbankTimestamp,
|
||||
@@ -36,16 +35,3 @@ export function previousClose(
|
||||
null
|
||||
);
|
||||
}
|
||||
export async function optional<T>(
|
||||
operation: () => Promise<T>,
|
||||
name: string,
|
||||
unavailable: string[],
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (!(error instanceof ServiceUnavailableException)) throw error;
|
||||
unavailable.push(name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-14
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
} from '../../../portfolio/presentation/portfolio.dto';
|
||||
import { stockPosition } from './stock-position';
|
||||
} from '../../portfolio/presentation/portfolio.dto';
|
||||
import { instrumentPosition } from './instrument-position';
|
||||
|
||||
const money = (value: string, currency = 'RUB') => ({ value, currency });
|
||||
const row = (
|
||||
@@ -34,13 +34,14 @@ const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
||||
|
||||
describe('stock position', () => {
|
||||
it('weights costs exactly and keeps preferred shares separate', () => {
|
||||
const result = stockPosition(
|
||||
const result = instrumentPosition(
|
||||
portfolio([
|
||||
row(),
|
||||
row('3', '20', '70'),
|
||||
row('100', '1', '100', 'preferred'),
|
||||
]),
|
||||
'common',
|
||||
'STOCK',
|
||||
);
|
||||
expect(result.quantity).toBe('5');
|
||||
expect(result.averagePurchasePrice).toEqual(money('16'));
|
||||
@@ -49,14 +50,18 @@ describe('stock position', () => {
|
||||
expect(result.returnPercent).toBe('25');
|
||||
expect(result.accounts[0].returnPercent).toBe('50');
|
||||
});
|
||||
it('does not round large amounts through floating point', () => {
|
||||
const result = stockPosition(
|
||||
portfolio([row('9007199254740993', '0.1', '900719925474099.3')]),
|
||||
'common',
|
||||
);
|
||||
expect(result.quantity).toBe('9007199254740993');
|
||||
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
||||
});
|
||||
it.each(['9007199254740993', '0.25'])(
|
||||
'preserves exact quantity %s',
|
||||
(quantity) => {
|
||||
const result = instrumentPosition(
|
||||
portfolio([row(quantity, '0.1', '900719925474099.3')]),
|
||||
'common',
|
||||
'STOCK',
|
||||
);
|
||||
expect(result.quantity).toBe(quantity);
|
||||
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
||||
},
|
||||
);
|
||||
it('does not sum currencies or treat unknown values as zero', () => {
|
||||
const second = {
|
||||
...row(),
|
||||
@@ -64,7 +69,11 @@ describe('stock position', () => {
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
};
|
||||
const result = stockPosition(portfolio([row(), second]), 'common');
|
||||
const result = instrumentPosition(
|
||||
portfolio([row(), second]),
|
||||
'common',
|
||||
'STOCK',
|
||||
);
|
||||
expect(result.quantity).toBe('4');
|
||||
expect(result.currentValue).toBeNull();
|
||||
expect(result.averagePurchasePrice).toBeNull();
|
||||
@@ -72,14 +81,14 @@ describe('stock position', () => {
|
||||
expect(result.returnPercent).toBeNull();
|
||||
});
|
||||
it('keeps no-position and offsetting long/short holdings honest', () => {
|
||||
expect(stockPosition(portfolio([]), 'common')).toMatchObject({
|
||||
expect(instrumentPosition(portfolio([]), 'common', 'STOCK')).toMatchObject({
|
||||
quantity: '0',
|
||||
accounts: [],
|
||||
currentValue: null,
|
||||
returnPercent: null,
|
||||
});
|
||||
expect(
|
||||
stockPosition(portfolio([row(), row('-2')]), 'common')
|
||||
instrumentPosition(portfolio([row(), row('-2')]), 'common', 'STOCK')
|
||||
.averagePurchasePrice,
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
confirmedCandleCoverage,
|
||||
type CandleRange,
|
||||
@@ -78,8 +78,37 @@ export class CandleRepository {
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === runId &&
|
||||
active.has(previous.attemptId)
|
||||
)
|
||||
return { work: previous, launch: false };
|
||||
) {
|
||||
const previousIntent = previous.intent as Prisma.JsonObject;
|
||||
const previousNow =
|
||||
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
||||
const nextPriority = Math.min(previous.priority, priority);
|
||||
if (
|
||||
nextPriority === previous.priority &&
|
||||
(!intent.force ||
|
||||
(previousIntent.force === true &&
|
||||
(!intent.now || intent.now <= previousNow)))
|
||||
)
|
||||
return { work: previous, launch: false };
|
||||
const work = await tx.tbankSyncWork.update({
|
||||
where: { id: previous.id },
|
||||
data: {
|
||||
priority: nextPriority,
|
||||
intent: {
|
||||
...previousIntent,
|
||||
...(intent.force
|
||||
? {
|
||||
force: true,
|
||||
...(intent.now && intent.now > previousNow
|
||||
? { now: intent.now }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
return { work, launch: false };
|
||||
}
|
||||
if (previous)
|
||||
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
||||
const data = {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankShare,
|
||||
TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
export type CatalogQuery = {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
export function stockPosition(portfolio: PortfolioDto, uid: string) {
|
||||
return instrumentPosition(portfolio, uid, 'STOCK');
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
jest.mock('../../prisma/prisma.service', () => ({ PrismaService: class {} }));
|
||||
|
||||
import {
|
||||
planInvestmentScreen,
|
||||
ledgerRange,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { dateOnlyBounds, moscowDate } from './investment-calendar';
|
||||
import {
|
||||
operationIntervals,
|
||||
type OperationInterval,
|
||||
} from './operations/operations.api';
|
||||
} from './operations/domain/operations-sync-plan';
|
||||
|
||||
export type InvestmentScreen = {
|
||||
screen:
|
||||
|
||||
@@ -356,7 +356,10 @@ export class BrokerageOperationsService {
|
||||
: {
|
||||
accountId,
|
||||
items: rows.map((row) => ({
|
||||
...mapBrokerageOperation(row.payload as TbankOperationItem),
|
||||
...mapBrokerageOperation(
|
||||
row.payload as TbankOperationItem,
|
||||
row.occurredAt,
|
||||
),
|
||||
...(accountId === 'all'
|
||||
? {
|
||||
account: {
|
||||
@@ -616,7 +619,10 @@ export class BrokerageOperationsService {
|
||||
),
|
||||
)
|
||||
.map((row) => ({
|
||||
...mapBrokerageOperation(row.payload as TbankOperationItem),
|
||||
...mapBrokerageOperation(
|
||||
row.payload as TbankOperationItem,
|
||||
row.occurredAt,
|
||||
),
|
||||
...(accountId === 'all'
|
||||
? {
|
||||
account: {
|
||||
|
||||
@@ -4,6 +4,17 @@ import {
|
||||
} from './brokerage-operation.mapper';
|
||||
|
||||
describe('mapBrokerageOperation', () => {
|
||||
it('keeps subsecond timestamps at persisted millisecond precision and the standalone epoch default', () => {
|
||||
expect(
|
||||
mapBrokerageOperation({
|
||||
date: { seconds: '1768000000', nanos: 600999999 },
|
||||
}).occurredAt,
|
||||
).toBe('2026-01-09T23:06:40.600Z');
|
||||
expect(mapBrokerageOperation({}).occurredAt).toBe(
|
||||
'1970-01-01T00:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['OPERATION_TYPE_COUPON', 'income'],
|
||||
['OPERATION_TYPE_DIVIDEND', 'income'],
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
OperationCategory,
|
||||
} from '../presentation/brokerage-operation.dto';
|
||||
|
||||
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
||||
|
||||
const types: Record<string, OperationCategory> = {
|
||||
OPERATION_TYPE_BUY: 'trade',
|
||||
OPERATION_TYPE_BUY_CARD: 'trade',
|
||||
@@ -42,10 +44,11 @@ export function operationCategory(type: string | undefined): OperationCategory {
|
||||
|
||||
export function mapBrokerageOperation(
|
||||
operation: TbankOperationItem,
|
||||
occurredAt = operationTimestamp(operation.date),
|
||||
): BrokerageOperationDto {
|
||||
return {
|
||||
id: operation.id ?? operation.cursor ?? '',
|
||||
occurredAt: timestamp(operation.date),
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
|
||||
category: operationCategory(operation.type),
|
||||
description: operation.description?.trim() || operation.name?.trim() || '',
|
||||
@@ -82,10 +85,11 @@ function money(value: TbankMoney | undefined) {
|
||||
: null;
|
||||
}
|
||||
|
||||
function timestamp(
|
||||
export function operationTimestamp(
|
||||
value: { seconds?: string | number; nanos?: number } | undefined,
|
||||
) {
|
||||
return new Date(
|
||||
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND,
|
||||
).toISOString();
|
||||
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND +
|
||||
Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND),
|
||||
);
|
||||
}
|
||||
|
||||
+10
-16
@@ -5,7 +5,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type {
|
||||
@@ -17,7 +16,10 @@ import {
|
||||
dateOnlyBounds,
|
||||
moscowDate,
|
||||
} from '../../investment-calendar';
|
||||
import { operationCategory } from './brokerage-operation.mapper';
|
||||
import {
|
||||
operationCategory,
|
||||
operationTimestamp,
|
||||
} from './brokerage-operation.mapper';
|
||||
import {
|
||||
type OperationInterval,
|
||||
operationIntervals,
|
||||
@@ -45,7 +47,6 @@ type PageProgress = {
|
||||
pages: number;
|
||||
};
|
||||
const progressItem = '';
|
||||
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
||||
|
||||
@Injectable()
|
||||
export class BrokerageOperationsRepository {
|
||||
@@ -650,13 +651,11 @@ export class BrokerageOperationsRepository {
|
||||
for (const operation of response.items ?? []) {
|
||||
if (!operation.id?.trim())
|
||||
throw new Error('Missing broker operation identity');
|
||||
const occurredAt = new Date(
|
||||
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
|
||||
Math.floor(
|
||||
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
|
||||
),
|
||||
);
|
||||
if (!Number.isFinite(occurredAt.valueOf()))
|
||||
const occurredAt = operationTimestamp(operation.date);
|
||||
if (
|
||||
operation.date?.seconds === undefined ||
|
||||
!Number.isFinite(occurredAt.valueOf())
|
||||
)
|
||||
throw new Error('Invalid operation timestamp');
|
||||
if (
|
||||
occurredAt < interval.from ||
|
||||
@@ -793,12 +792,7 @@ export class BrokerageOperationsRepository {
|
||||
if (previous && isDeepStrictEqual(previous.payload, operation))
|
||||
continue;
|
||||
const data = {
|
||||
occurredAt: new Date(
|
||||
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
|
||||
Math.floor(
|
||||
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
|
||||
),
|
||||
),
|
||||
occurredAt: operationTimestamp(operation.date),
|
||||
instrumentUid: operation.instrumentUid ?? null,
|
||||
category: operationCategory(operation.type),
|
||||
payload: row.payload as Prisma.InputJsonObject,
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
|
||||
export { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||
|
||||
export {
|
||||
operationIntervals,
|
||||
type OperationInterval,
|
||||
} from './domain/operations-sync-plan';
|
||||
export type { OperationInterval } from './domain/operations-sync-plan';
|
||||
|
||||
export { operationCategories } from './presentation/brokerage-operation.dto';
|
||||
export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
import {
|
||||
@@ -67,8 +68,23 @@ export class ForecastService {
|
||||
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'
|
||||
? {
|
||||
...payload,
|
||||
positionIsStale: tbankSourceNeedsRefresh(
|
||||
'portfolio',
|
||||
new Date(payload.positionAt),
|
||||
new Date(Date.now()),
|
||||
),
|
||||
}
|
||||
: payload;
|
||||
return {
|
||||
data: stored ? (stored.payload as T) : null,
|
||||
data,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: input.scopeKey,
|
||||
dataRevision: String(stored?.dataRevision ?? 0),
|
||||
@@ -80,7 +96,10 @@ export class ForecastService {
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
fetchedAt: first ? new Date(first) : null,
|
||||
freshForMs: Number.POSITIVE_INFINITY,
|
||||
freshForMs:
|
||||
data && 'positionIsStale' in data && data.positionIsStale === true
|
||||
? 0
|
||||
: Number.POSITIVE_INFINITY,
|
||||
work: work?.id ? { ...work, id: work.id } : null,
|
||||
dependencies,
|
||||
}),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
@@ -511,12 +512,3 @@ export class PortfolioService {
|
||||
: 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { totalsByCurrency } from './infrastructure/tbank-portfolio.mapper';
|
||||
export {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Req,
|
||||
UseGuards,
|
||||
ValidationPipe,
|
||||
type ValidationError,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiAcceptedResponse,
|
||||
@@ -58,8 +57,8 @@ export class TbankSyncController {
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
exceptionFactory: (message) =>
|
||||
new BadRequestException({ message: descriptorErrors(message) }),
|
||||
validationError: { target: false, value: false },
|
||||
exceptionFactory: (message) => new BadRequestException({ message }),
|
||||
}),
|
||||
)
|
||||
input: unknown,
|
||||
@@ -75,13 +74,3 @@ export class TbankSyncController {
|
||||
return this.sync.status(request.user.sub, id);
|
||||
}
|
||||
}
|
||||
|
||||
function descriptorErrors(
|
||||
errors: ValidationError[],
|
||||
prefix = '',
|
||||
): ValidationError[] {
|
||||
return errors.flatMap((error) => [
|
||||
{ ...error, property: `${prefix}${error.property}` },
|
||||
...descriptorErrors(error.children ?? [], `${prefix}${error.property}/`),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { Prisma } from '../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service';
|
||||
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
|
||||
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||
export type ScreenIntent = { scope: InvestmentScreenDto; now: string };
|
||||
export type ScreenProgress = {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TbankAccessService,
|
||||
TbankMembershipService,
|
||||
InstrumentMetadataService,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../integrations/tbank/tbank.api';
|
||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||
import {
|
||||
@@ -30,7 +31,6 @@ import { IncomeAnalyticsService } from '../income/income-read.api';
|
||||
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
|
||||
import { planInvestmentScreen } from '../investment-sync-plan';
|
||||
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service';
|
||||
import {
|
||||
TbankSyncRepository,
|
||||
type ScreenIntent,
|
||||
@@ -303,6 +303,7 @@ export class TbankSyncService {
|
||||
},
|
||||
now,
|
||||
true,
|
||||
0,
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -327,10 +328,17 @@ export class TbankSyncService {
|
||||
return this.sourceStatus(userId, saved.work);
|
||||
const intent = saved.work.intent as unknown as ScreenIntent;
|
||||
await this.access.local(userId, intent.scope.accountId);
|
||||
const running = saved.children.some((child) => child.state === 'running');
|
||||
const children = await Promise.all(
|
||||
saved.children.map((child) =>
|
||||
child.state === 'superseded'
|
||||
? this.operations.observe(userId, child.id)
|
||||
: Promise.resolve(child),
|
||||
),
|
||||
);
|
||||
const running = children.some((child) => child.state === 'running');
|
||||
const failed =
|
||||
saved.work.errorCode ??
|
||||
saved.children.find((child) => child.errorCode)?.errorCode ??
|
||||
children.find((child) => child.errorCode)?.errorCode ??
|
||||
null;
|
||||
const state =
|
||||
saved.work.processRunId !== this.runId &&
|
||||
@@ -340,7 +348,7 @@ export class TbankSyncService {
|
||||
? 'running'
|
||||
: failed
|
||||
? 'failed'
|
||||
: saved.children.some((child) => child.state === 'cancelled')
|
||||
: children.some((child) => child.state === 'cancelled')
|
||||
? 'cancelled'
|
||||
: 'succeeded';
|
||||
const resources = await this.observe(userId, intent.scope);
|
||||
@@ -355,9 +363,8 @@ export class TbankSyncService {
|
||||
state,
|
||||
errorCode: failed,
|
||||
progress: {
|
||||
completed: saved.children.filter((child) => child.state !== 'running')
|
||||
.length,
|
||||
total: saved.children.length,
|
||||
completed: children.filter((child) => child.state !== 'running').length,
|
||||
total: children.length,
|
||||
discovering: !saved.progress.dispatched,
|
||||
},
|
||||
resources,
|
||||
|
||||
Reference in New Issue
Block a user