refactor: replace ignored numeric literals with constants
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m56s

This commit is contained in:
2026-09-28 11:24:59 +03:00
parent 9b3558f96c
commit 9cc9fbdcff
60 changed files with 510 additions and 283 deletions
+5 -4
View File
@@ -246,10 +246,11 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
сохраняй; generated Prisma/protocol code вручную не меняй. сохраняй; generated Prisma/protocol code вручную не меняй.
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule `@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм, отключено в пользу TS-версии. Общего списка `ignore` для чисел и bigint нет:
индексов, default values, enum/type declarations и readonly properties заданы значения, проверяемые правилом, получают предметные имена у владельца.
в ESLint config. Test fixtures/expected values остаются inline, остальные lint Исключения для индексов, default values, enum/type declarations и readonly
правила продолжают действовать. Numeric policy получает предметное имя и единицы; properties заданы в ESLint config. Test fixtures/expected values остаются inline,
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
semantic magic strings проверяются conventions/review без нового инструмента. semantic magic strings проверяются conventions/review без нового инструмента.
## Logging ## Logging
-24
View File
@@ -70,30 +70,6 @@ export default defineConfig(
'@typescript-eslint/no-magic-numbers': [ '@typescript-eslint/no-magic-numbers': [
'error', 'error',
{ {
ignore: [
-60,
-24,
-10,
-2,
-1,
0,
1,
2,
10,
24,
60,
'-60n',
'-24n',
'-10n',
'-2n',
'-1n',
'0n',
'1n',
'2n',
'10n',
'24n',
'60n',
],
ignoreArrayIndexes: true, ignoreArrayIndexes: true,
ignoreDefaultValues: true, ignoreDefaultValues: true,
ignoreEnums: true, ignoreEnums: true,
+37 -21
View File
@@ -1,3 +1,19 @@
export const DECIMAL_RADIX = 10n;
const SIGN_LENGTH = 1;
const NEGATIVE_SIGN = -1n;
const POSITIVE_SIGN = 1n;
const ZERO_UNITS = 0n;
const EQUAL = 0;
const GREATER_THAN = 1;
const LESS_THAN = -1;
const INTEGER_SCALE = 0;
const MIN_WHOLE_DIGITS = 1;
const DIGITS_START = 0;
const PREVIOUS_DIGIT_OFFSET = 1;
const DIGIT_STEP = 1;
const ROUNDING_DIVISOR = 2n;
export type Decimal = { scale: number; value: bigint }; export type Decimal = { scale: number; value: bigint };
export function decimalFromString(value: string): Decimal { export function decimalFromString(value: string): Decimal {
@@ -6,9 +22,9 @@ export function decimalFromString(value: string): Decimal {
throw new Error('Invalid decimal string'); throw new Error('Invalid decimal string');
} }
const negative = value.startsWith('-'); const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); const [whole, fraction = ''] = (negative ? value.slice(SIGN_LENGTH) : value).split('.');
return { return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`), value: (negative ? NEGATIVE_SIGN : POSITIVE_SIGN) * BigInt(`${whole}${fraction}`),
scale: fraction.length, scale: fraction.length,
}; };
} }
@@ -17,8 +33,8 @@ export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale); const scale = Math.max(left.scale, right.scale);
return { return {
value: value:
left.value * 10n ** BigInt(scale - left.scale) + left.value * DECIMAL_RADIX ** BigInt(scale - left.scale) +
right.value * 10n ** BigInt(scale - right.scale), right.value * DECIMAL_RADIX ** BigInt(scale - right.scale),
scale, scale,
}; };
} }
@@ -32,37 +48,37 @@ export function multiply(left: Decimal, right: Decimal): Decimal {
} }
export function isZero(value: Decimal): boolean { export function isZero(value: Decimal): boolean {
return value.value === 0n; return value.value === ZERO_UNITS;
} }
export function compare(left: Decimal, right: Decimal): number { export function compare(left: Decimal, right: Decimal): number {
const difference = subtract(left, right).value; const difference = subtract(left, right).value;
return difference === 0n ? 0 : difference > 0n ? 1 : -1; return difference === ZERO_UNITS ? EQUAL : difference > ZERO_UNITS ? GREATER_THAN : LESS_THAN;
} }
export function formatDecimal({ value, scale }: Decimal): string { export function formatDecimal({ value, scale }: Decimal): string {
if (value === 0n) return '0'; if (value === ZERO_UNITS) return '0';
const sign = value < 0n ? '-' : ''; const sign = value < ZERO_UNITS ? '-' : '';
if (scale === 0) return `${sign}${value < 0n ? -value : value}`; if (scale === INTEGER_SCALE) return `${sign}${value < ZERO_UNITS ? -value : value}`;
const digits = (value < 0n ? -value : value) const digits = (value < ZERO_UNITS ? -value : value)
.toString() .toString()
.padStart(scale + 1, '0'); .padStart(scale + MIN_WHOLE_DIGITS, '0');
const whole = digits.slice(0, -scale) || '0'; const whole = digits.slice(DIGITS_START, -scale) || '0';
const rawFraction = digits.slice(-scale); const rawFraction = digits.slice(-scale);
let fractionEnd = rawFraction.length; let fractionEnd = rawFraction.length;
while (rawFraction.charAt(fractionEnd - 1) === '0') fractionEnd -= 1; while (rawFraction.charAt(fractionEnd - PREVIOUS_DIGIT_OFFSET) === '0') fractionEnd -= DIGIT_STEP;
const fraction = rawFraction.slice(0, fractionEnd); const fraction = rawFraction.slice(DIGITS_START, fractionEnd);
const fractionalPart = fraction ? '.' + fraction : ''; const fractionalPart = fraction ? '.' + fraction : '';
return `${sign}${whole}${fractionalPart}`; return `${sign}${whole}${fractionalPart}`;
} }
// Round half away from zero; callers choose presentation precision explicitly. // Round half away from zero; callers choose presentation precision explicitly.
export function divide(left: Decimal, right: Decimal, scale: number): Decimal { export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero'); if (right.value === ZERO_UNITS) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale); const numerator = left.value * DECIMAL_RADIX ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale); const denominator = right.value * DECIMAL_RADIX ** BigInt(left.scale);
const sign = (numerator < 0n) !== (denominator < 0n) ? -1n : 1n; const sign = (numerator < ZERO_UNITS) !== (denominator < ZERO_UNITS) ? NEGATIVE_SIGN : POSITIVE_SIGN;
const n = numerator < 0n ? -numerator : numerator; const n = numerator < ZERO_UNITS ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator; const d = denominator < ZERO_UNITS ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale }; return { value: sign * ((n + d / ROUNDING_DIVISOR) / d), scale };
} }
+2
View File
@@ -1,2 +1,4 @@
export const ISO_DATE_LENGTH = 10;
export const ISO_DATE_START = 0;
export const MILLISECONDS_PER_SECOND = 1_000; export const MILLISECONDS_PER_SECOND = 1_000;
export const MILLISECONDS_PER_DAY = 86_400_000; export const MILLISECONDS_PER_DAY = 86_400_000;
+5 -2
View File
@@ -1,3 +1,6 @@
const MIN_PORT = 1;
const MIN_REQUESTS_PER_SECOND = 1;
const DEFAULT_PORT = 3000; const DEFAULT_PORT = 3000;
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5; const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
const MAX_PORT = 65_535; const MAX_PORT = 65_535;
@@ -56,7 +59,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
const parsedPort = Number(port); const parsedPort = Number(port);
if ( if (
!Number.isInteger(parsedPort) || !Number.isInteger(parsedPort) ||
parsedPort < 1 || parsedPort < MIN_PORT ||
parsedPort > MAX_PORT parsedPort > MAX_PORT
) { ) {
throw new Error('PORT must be a valid port number'); throw new Error('PORT must be a valid port number');
@@ -84,7 +87,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
} }
const parsedTbankRateLimit = Number(tbankRateLimit); const parsedTbankRateLimit = Number(tbankRateLimit);
if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < 1) { if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < MIN_REQUESTS_PER_SECOND) {
throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer'); throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer');
} }
+11 -5
View File
@@ -1,6 +1,12 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { constants as httpStatus } from 'node:http2'; import { constants as httpStatus } from 'node:http2';
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
const FIRST_PAGE_OFFSET = 0;
const EXPECTED_CURSOR_ROWS = 1;
const EMPTY_PAGE_SIZE = 0;
const MIN_CURSOR_VALUE = 0;
const FALLBACK_PAGE_SIZE = 100; const FALLBACK_PAGE_SIZE = 100;
const baseUrl = 'https://iss.moex.com/iss'; const baseUrl = 'https://iss.moex.com/iss';
@@ -52,7 +58,7 @@ export class MoexClient {
if ( if (
value === null || value === null ||
pricePercent === null || pricePercent === null ||
Number(pricePercent) <= 0 || Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE ||
!date || !date ||
!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{4}-\d{2}-\d{2}$/.test(date) ||
!Number.isFinite(Date.parse(date)) || !Number.isFinite(Date.parse(date)) ||
@@ -114,7 +120,7 @@ export class MoexClient {
paginated = true, paginated = true,
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = []; const rows: Record<string, unknown>[] = [];
let start = 0; let start = FIRST_PAGE_OFFSET;
for (;;) { for (;;) {
const query = new URLSearchParams({ const query = new URLSearchParams({
'iss.meta': 'off', 'iss.meta': 'off',
@@ -139,11 +145,11 @@ export class MoexClient {
const total = integer(cursor?.total); const total = integer(cursor?.total);
const pageSize = integer(cursor?.pagesize); const pageSize = integer(cursor?.pagesize);
if ( if (
cursors.length !== 1 || cursors.length !== EXPECTED_CURSOR_ROWS ||
index !== start || index !== start ||
total === null || total === null ||
pageSize === null || pageSize === null ||
pageSize === 0 || pageSize === EMPTY_PAGE_SIZE ||
current.length !== Math.min(pageSize, total - start) current.length !== Math.min(pageSize, total - start)
) { ) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
@@ -207,5 +213,5 @@ function currencyText(value: unknown): null | string {
function integer(value: unknown): null | number { function integer(value: unknown): null | number {
const result = Number(numberText(value) ?? NaN); const result = Number(numberText(value) ?? NaN);
return Number.isSafeInteger(result) && result >= 0 ? result : null; return Number.isSafeInteger(result) && result >= MIN_CURSOR_VALUE ? result : null;
} }
+8 -3
View File
@@ -2,6 +2,11 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
const EXPECTED_REGISTRY_TABLES = 1;
const RATING_COLUMN = 2;
const REGISTRY_CACHE_TTL_MS = 21_600_000; const REGISTRY_CACHE_TTL_MS = 21_600_000;
const REQUEST_TIMEOUT_MS = 10_000; const REQUEST_TIMEOUT_MS = 10_000;
const COLUMN_COUNT = 6; const COLUMN_COUNT = 6;
@@ -55,7 +60,7 @@ export class NcrRatingsClient {
.map((_, cell) => $(cell).text().trim()) .map((_, cell) => $(cell).text().trim())
.get(); .get();
if ( if (
table.length !== 1 || table.length !== EXPECTED_REGISTRY_TABLES ||
headings.length !== COLUMN_COUNT || headings.length !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' || headings[2] !== 'Рейтинг' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants. // eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
@@ -73,7 +78,7 @@ export class NcrRatingsClient {
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase(); const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
// Unassigned issues cannot safely be matched to a held instrument. // Unassigned issues cannot safely be matched to a held instrument.
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return; if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim(); const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim();
const withdrawn = raw.toLowerCase() === 'рейтинг отозван'; const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
if ( if (
!withdrawn && !withdrawn &&
@@ -93,7 +98,7 @@ export class NcrRatingsClient {
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
if ( if (
!Number.isFinite(Date.parse(date)) || !Number.isFinite(Date.parse(date)) ||
new Date(date).toISOString().slice(0, 10) !== date new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date
) { ) {
throw new Error('NCR date invalid'); throw new Error('NCR date invalid');
} }
+3 -1
View File
@@ -1,12 +1,14 @@
import { Decimal } from '../../common/decimal'; import { Decimal } from '../../common/decimal';
const MISSING_PROTO_COMPONENT = 0;
const NANO = 1_000_000_000n; const NANO = 1_000_000_000n;
export type ProtoDecimal = { nano?: number | string; units?: number | string }; export type ProtoDecimal = { nano?: number | string; units?: number | string };
export function decimal(value: ProtoDecimal): Decimal { export function decimal(value: ProtoDecimal): Decimal {
return { return {
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0), value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
scale: 9, scale: 9,
}; };
} }
@@ -13,6 +13,11 @@ import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { tbankSourceTtlMs } from './tbank-source.policy'; import { tbankSourceTtlMs } from './tbank-source.policy';
const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0;
const REVISION_INCREMENT = 1;
const MISSING_DATA_REVISION = 0;
export type MetadataIdentity = { export type MetadataIdentity = {
figi?: null | string; figi?: null | string;
instrumentUid?: null | string; instrumentUid?: null | string;
@@ -59,7 +64,7 @@ export class InstrumentMetadataRepository {
), ),
]; ];
for ( for (
let offset = 0; let offset = FIRST_BATCH_OFFSET;
offset < values.length; offset < values.length;
offset += metadataReadBatchSize offset += metadataReadBatchSize
) { ) {
@@ -81,7 +86,7 @@ export class InstrumentMetadataRepository {
]; ];
const instruments: MetadataRow[] = []; const instruments: MetadataRow[] = [];
for ( for (
let offset = 0; let offset = FIRST_BATCH_OFFSET;
offset < uids.length; offset < uids.length;
offset += metadataReadBatchSize offset += metadataReadBatchSize
) { ) {
@@ -229,8 +234,8 @@ export class InstrumentMetadataRepository {
...data, ...data,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.metadata, payload) ? increment: isDeepStrictEqual(previous?.metadata, payload) ?
0 : UNCHANGED_REVISION_INCREMENT :
1, REVISION_INCREMENT,
}, },
}, },
}); });
@@ -252,8 +257,8 @@ export class InstrumentMetadataRepository {
fetchedAt: now, fetchedAt: now,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ? increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ?
0 : UNCHANGED_REVISION_INCREMENT :
1, REVISION_INCREMENT,
}, },
}, },
}); });
@@ -323,7 +328,7 @@ function selectMetadata(
dataRevision: used dataRevision: used
.map( .map(
(row) => (row) =>
`${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`, `${row.lookup?.dataRevision ?? MISSING_DATA_REVISION}:${row.instrument?.dataRevision ?? MISSING_DATA_REVISION}`,
) )
.join('/'), .join('/'),
}; };
@@ -13,6 +13,10 @@ import { supportedTbankAccounts } from './tbank-account.policy';
import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
const EXPECTED_UPDATED_CONNECTIONS = 1;
const MEMBERSHIP_REVISION_INCREMENT = 1;
const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0;
export type TbankSyncAttempt = { export type TbankSyncAttempt = {
attemptId: string; attemptId: string;
id: string; id: string;
@@ -101,7 +105,7 @@ export class TbankAccessService {
}, },
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
}); });
return result.count === 1; return result.count === EXPECTED_UPDATED_CONNECTIONS;
} }
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> { async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
@@ -184,7 +188,7 @@ export class TbankAccessService {
where: { userId_revision: { userId, revision } }, where: { userId_revision: { userId, revision } },
data: { data: {
membershipAt: fetchedAt, membershipAt: fetchedAt,
membershipRevision: { increment: changed ? 1 : 0 }, membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
}, },
}); });
if (completeWork) { if (completeWork) {
@@ -1,4 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
import { TbankAccount } from './tbank.client'; import { TbankAccount } from './tbank.client';
const accountTypes = { const accountTypes = {
@@ -43,5 +43,5 @@ function openedOn(seconds: number | string | undefined): null | string {
if (seconds === undefined) return null; if (seconds === undefined) return null;
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
if (Number.isNaN(date.valueOf())) return null; if (Number.isNaN(date.valueOf())) return null;
return date.toISOString().slice(0, 10); return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
+14 -7
View File
@@ -30,6 +30,13 @@ import { StructuredLogger } from '../../common/logging/structured-logger.service
import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
const INITIAL_REQUEST_COUNT = 0;
const MIN_RATE_DELAY_MS = 1;
const FIRST_ATTEMPT = 1;
const ATTEMPT_INCREMENT = 1;
const NO_RPC_ATTEMPTS = 0;
const NEXT_DAY_OFFSET = 1;
const RATE_WINDOW_MS = 1_000; const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3; const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2; const RPC_MAX_ATTEMPTS = 2;
@@ -124,7 +131,7 @@ type TbankNamespace = {
class RateLimiter { class RateLimiter {
private readonly waiting: (() => void)[] = []; private readonly waiting: (() => void)[] = [];
private starts = 0; private starts = INITIAL_REQUEST_COUNT;
private windowStartedAt = Date.now(); private windowStartedAt = Date.now();
constructor(private readonly perSecond: number) {} constructor(private readonly perSecond: number) {}
@@ -149,16 +156,16 @@ class RateLimiter {
const now = Date.now(); const now = Date.now();
if (now - this.windowStartedAt >= RATE_WINDOW_MS) { if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
this.windowStartedAt = now; this.windowStartedAt = now;
this.starts = 0; this.starts = INITIAL_REQUEST_COUNT;
} }
while (this.starts < this.perSecond && this.waiting.length) { while (this.starts < this.perSecond && this.waiting.length) {
this.starts += 1; this.starts++;
this.waiting.shift()?.(); this.waiting.shift()?.();
} }
if (this.waiting.length && this.starts === this.perSecond) { if (this.waiting.length && this.starts === this.perSecond) {
setTimeout( setTimeout(
() => this.drain(), () => this.drain(),
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)), Math.max(MIN_RATE_DELAY_MS, RATE_WINDOW_MS - (now - this.windowStartedAt)),
); );
} }
} }
@@ -472,7 +479,7 @@ export class TbankClient {
): Promise<Response> { ): Promise<Response> {
const metadataCall = method === 'getInstrumentBy'; const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS; const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) { for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
const startedAt = performance.now(); const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE; const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try { try {
@@ -520,7 +527,7 @@ export class TbankClient {
this.logger.event('error', 'tbank.rpc.failed', { this.logger.event('error', 'tbank.rpc.failed', {
operation: method, operation: method,
...failureContext, ...failureContext,
attempts: queueFull && !metadataCall ? 0 : attempts, attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
if ( if (
@@ -564,6 +571,6 @@ export class TbankClient {
// Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)). // Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
function utcSeconds(date: string, nextDay = false): number { function utcSeconds(date: string, nextDay = false): number {
const instant = new Date(`${date}T00:00:00Z`); const instant = new Date(`${date}T00:00:00Z`);
if (nextDay) instant.setUTCDate(instant.getUTCDate() + 1); if (nextDay) instant.setUTCDate(instant.getUTCDate() + NEXT_DAY_OFFSET);
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND); return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
} }
+5 -3
View File
@@ -1,5 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const VERSION_SIZE_BYTES = 1;
const KEY_SIZE_BYTES = 32; const KEY_SIZE_BYTES = 32;
const VERSION = 1; const VERSION = 1;
@@ -34,15 +36,15 @@ export class TbankCrypto {
decrypt(payload: string): string { decrypt(payload: string): string {
const bytes = Buffer.from(payload, 'base64'); const bytes = Buffer.from(payload, 'base64');
if (bytes[0] !== VERSION || bytes.length < 1 + NONCE_SIZE + TAG_SIZE) { if (bytes[0] !== VERSION || bytes.length < VERSION_SIZE_BYTES + NONCE_SIZE + TAG_SIZE) {
throw new Error('Invalid encrypted token'); throw new Error('Invalid encrypted token');
} }
const nonce = bytes.subarray(1, 1 + NONCE_SIZE); const nonce = bytes.subarray(VERSION_SIZE_BYTES, VERSION_SIZE_BYTES + NONCE_SIZE);
const tag = bytes.subarray(-TAG_SIZE); const tag = bytes.subarray(-TAG_SIZE);
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce); const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
decipher.setAuthTag(tag); decipher.setAuthTag(tag);
return Buffer.concat([ return Buffer.concat([
decipher.update(bytes.subarray(1 + NONCE_SIZE, -TAG_SIZE)), decipher.update(bytes.subarray(VERSION_SIZE_BYTES + NONCE_SIZE, -TAG_SIZE)),
decipher.final(), decipher.final(),
]).toString('utf8'); ]).toString('utf8');
} }
+3 -1
View File
@@ -11,6 +11,8 @@ import { Session, User } from '../../../../generated/prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types'; import { AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1;
const PASSWORD_HASH_ROUNDS = 12; const PASSWORD_HASH_ROUNDS = 12;
const REFRESH_TTL_MS = 604_800_000; const REFRESH_TTL_MS = 604_800_000;
@@ -180,7 +182,7 @@ export class AuthService {
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data, data,
}); });
if (updated.count !== 1) { if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
} }
} else { } else {
+6 -3
View File
@@ -7,6 +7,9 @@ import {
ValidatorConstraint, ValidatorConstraint,
} from 'class-validator'; } from 'class-validator';
const EMAIL_PART_COUNT = 2;
const FIRST_CHARACTER_INDEX = 0;
const MAX_EMAIL_LENGTH = 254; const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32; const ASCII_SPACE = 32;
const ASCII_DELETE = 127; const ASCII_DELETE = 127;
@@ -19,14 +22,14 @@ class EmailConstraint {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
value.length <= MAX_EMAIL_LENGTH && value.length <= MAX_EMAIL_LENGTH &&
value.split('@').length === 2 && value.split('@').length === EMAIL_PART_COUNT &&
!value !value
.split('') .split('')
.some( .some(
(character) => (character) =>
/\s/.test(character) || /\s/.test(character) ||
character.charCodeAt(0) < ASCII_SPACE || character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE ||
character.charCodeAt(0) === ASCII_DELETE, character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE,
) && ) &&
value.split('@').every(Boolean) value.split('@').every(Boolean)
); );
@@ -5,6 +5,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import { import {
MAX_CARD_NAME_LENGTH, MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH,
@@ -18,6 +19,9 @@ import {
CreateBankOperationDto, CreateBankOperationDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
const NEXT_MONTH_OFFSET = 1;
const PREVIOUS_MONTH_LAST_DAY = 0;
export const BankErrorCode = { export const BankErrorCode = {
InvalidInput: 'BANK_INVALID_INPUT', InvalidInput: 'BANK_INVALID_INPUT',
VersionConflict: 'BANK_VERSION_CONFLICT', VersionConflict: 'BANK_VERSION_CONFLICT',
@@ -45,8 +49,8 @@ export function periodBounds(query: BankPeriodQueryDto) {
const from = `${query.month}-01`; const from = `${query.month}-01`;
if (!isCalendarDate(from)) return fail(); if (!isCalendarDate(from)) return fail();
const end = new Date(`${from}T00:00:00Z`); const end = new Date(`${from}T00:00:00Z`);
end.setUTCMonth(end.getUTCMonth() + 1, 0); end.setUTCMonth(end.getUTCMonth() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY);
return { from, to: end.toISOString().slice(0, 10) }; return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) };
} }
if ( if (
query.month || query.month ||
@@ -19,6 +19,9 @@ import {
fingerprint, fingerprint,
missing, missing,
} from './bank-card-input'; } from './bank-card-input';
const EXPECTED_UPDATED_CARDS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankCardsService { export class BankCardsService {
@@ -149,7 +152,7 @@ export class BankCardsService {
}), }),
}, },
}); });
if (changed.count !== 1) conflict(); if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.cardView(tx, await this.repository.card(tx, userId, id)); return this.cardView(tx, await this.repository.card(tx, userId, id));
}); });
} }
@@ -5,8 +5,9 @@ import {
Prisma, Prisma,
} from '../../../../../generated/prisma/client'; } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
import { rubMoney } from '../domain/rub-money'; import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { import {
BankOperationDto, BankOperationDto,
@@ -26,6 +27,9 @@ import {
operationInput, operationInput,
periodBounds, periodBounds,
} from './bank-card-input'; } from './bank-card-input';
const EXPECTED_UPDATED_ROWS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankOperationsService { export class BankOperationsService {
@@ -77,7 +81,7 @@ export class BankOperationsService {
row.kind === BankOperationKind.Expense || source ? row.kind === BankOperationKind.Expense || source ?
rubMoney( rubMoney(
(source?.amountKopecks ?? row.amountKopecks) - (source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
) : ) :
null, null,
date: row.date, date: row.date,
@@ -170,7 +174,7 @@ export class BankOperationsService {
}, },
data: { version: { increment: 1 } }, data: { version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) { ) {
conflict(); conflict();
} }
@@ -184,7 +188,7 @@ export class BankOperationsService {
}); });
if (input.date < source.date) fail(BankErrorCode.InvalidDate); if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if ( if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
input.amountKopecks > input.amountKopecks >
source.amountKopecks source.amountKopecks
) { ) {
@@ -199,7 +203,7 @@ export class BankOperationsService {
refunds.length && refunds.length &&
(input.cardId !== old.cardId || (input.cardId !== old.cardId ||
refunds.some((row) => row.date < input.date) || refunds.some((row) => row.date < input.date) ||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) >
input.amountKopecks) input.amountKopecks)
) { ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
@@ -268,7 +272,7 @@ export class BankOperationsService {
where: { id, userId, version: dto.version, deletedAt: null }, where: { id, userId, version: dto.version, deletedAt: null },
data: { ...input, version: { increment: 1 } }, data: { ...input, version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) { ) {
conflict(); conflict();
} }
@@ -308,7 +312,7 @@ export class BankOperationsService {
where: { id: source.id, userId, version: source.version }, where: { id: source.id, userId, version: source.version },
data: { version: { increment: 1 } }, data: { version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) { ) {
conflict(); conflict();
} }
@@ -319,7 +323,7 @@ export class BankOperationsService {
where: { id, userId, version, deletedAt: null }, where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } }, data: { deletedAt: new Date(), version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) { ) {
conflict(); conflict();
} }
@@ -338,7 +342,7 @@ export class BankOperationsService {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
page = query.page ?? 1; page = query.page ?? FIRST_PAGE;
const where: Prisma.BankCardOperationWhereInput = { const where: Prisma.BankCardOperationWhereInput = {
userId, userId,
deletedAt: null, deletedAt: null,
@@ -351,7 +355,7 @@ export class BankOperationsService {
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
where, where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * limit, skip: (page - FIRST_PAGE) * limit,
take: limit, take: limit,
}); });
return { return {
@@ -5,6 +5,10 @@ import { PrismaService } from '../../../../prisma/prisma.service';
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { conflict, missing, name } from './bank-card-input'; import { conflict, missing, name } from './bank-card-input';
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
@Injectable() @Injectable()
export class ExpenseCategoriesService { export class ExpenseCategoriesService {
constructor( constructor(
@@ -62,7 +66,7 @@ export class ExpenseCategoriesService {
version: { increment: 1 }, version: { increment: 1 },
}, },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_CATEGORIES
) { ) {
conflict(); conflict();
} }
@@ -70,7 +74,7 @@ export class ExpenseCategoriesService {
id, id,
name: label, name: label,
isDefault: row.seedKey === 'other', isDefault: row.seedKey === 'other',
version: version + 1, version: version + VERSION_INCREMENT,
}; };
}); });
} }
@@ -1,3 +1,10 @@
export const FIRST_PAGE = 1;
export const MIN_ENTITY_VERSION = 1;
export const MIN_CATEGORY_NAME_LENGTH = 1;
export const MIN_CARD_NAME_LENGTH = 1;
export const MAX_CARD_NAME_LENGTH = 100; export const MAX_CARD_NAME_LENGTH = 100;
export const MAX_CATEGORY_NAME_LENGTH = 80; export const MAX_CATEGORY_NAME_LENGTH = 80;
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500; export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
@@ -1,4 +1,6 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import { BankOperationKind } from './bank-operation-kind'; import { BankOperationKind } from './bank-operation-kind';
import { ZERO_KOPECKS } from './rub-money';
export function moscowToday(now = new Date()): string { export function moscowToday(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', { return new Intl.DateTimeFormat('en-CA', {
@@ -10,11 +12,11 @@ export function moscowToday(now = new Date()): string {
} }
export function isCalendarDate(value: string): boolean { export function isCalendarDate(value: string): boolean {
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; if (value.length !== ISO_DATE_LENGTH || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
if (value.startsWith('0000')) return false; if (value.startsWith('0000')) return false;
const date = new Date(`${value}T00:00:00.000Z`); const date = new Date(`${value}T00:00:00.000Z`);
return ( return (
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value !Number.isNaN(date.getTime()) && date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === value
); );
} }
@@ -63,8 +65,8 @@ export function bankSummary(
kind: string; kind: string;
}>, }>,
) { ) {
let expenses = 0n; let expenses = ZERO_KOPECKS;
let refunds = 0n; let refunds = ZERO_KOPECKS;
const categories = new Map< const categories = new Map<
string, string,
{ categoryId: string; expenses: bigint; net: bigint; refunds: bigint } { categoryId: string; expenses: bigint; net: bigint; refunds: bigint }
@@ -81,19 +83,19 @@ export function bankSummary(
const expense = const expense =
operation.kind === BankOperationKind.Expense ? operation.kind === BankOperationKind.Expense ?
operation.amountKopecks : operation.amountKopecks :
0n; ZERO_KOPECKS;
const refund = const refund =
operation.kind === BankOperationKind.Refund ? operation.kind === BankOperationKind.Refund ?
operation.amountKopecks : operation.amountKopecks :
0n; ZERO_KOPECKS;
expenses += expense; expenses += expense;
refunds += refund; refunds += refund;
if (operation.categoryId !== null) { if (operation.categoryId !== null) {
const category = categories.get(operation.categoryId) ?? { const category = categories.get(operation.categoryId) ?? {
categoryId: operation.categoryId, categoryId: operation.categoryId,
expenses: 0n, expenses: ZERO_KOPECKS,
refunds: 0n, refunds: ZERO_KOPECKS,
net: 0n, net: ZERO_KOPECKS,
}; };
category.expenses += expense; category.expenses += expense;
category.refunds += refund; category.refunds += refund;
@@ -1,6 +1,10 @@
import type { Money } from '../../../../common/money.types'; import type { Money } from '../../../../common/money.types';
import { decimalFromString, formatDecimal } from '../../../../common/decimal'; import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
export const ZERO_KOPECKS = 0n;
const RUB_DECIMAL_PLACES = 2;
const MIN_KOPECKS = -9_223_372_036_854_775_808n; const MIN_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_KOPECKS = 9_223_372_036_854_775_807n; const MAX_KOPECKS = 9_223_372_036_854_775_807n;
@@ -14,14 +18,14 @@ export function parseRubMoney(money: Money, positive = false): bigint {
throw new TypeError('Expected RUB money with a decimal string'); throw new TypeError('Expected RUB money with a decimal string');
} }
const amount = decimalFromString(money.value); const amount = decimalFromString(money.value);
if (amount.scale > 2) { if (amount.scale > RUB_DECIMAL_PLACES) {
throw new RangeError('RUB money supports at most two decimal places'); throw new RangeError('RUB money supports at most two decimal places');
} }
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale); const kopecks = amount.value * DECIMAL_RADIX ** BigInt(RUB_DECIMAL_PLACES - amount.scale);
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) { if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) {
throw new RangeError('RUB money is outside the signed 64-bit range'); throw new RangeError('RUB money is outside the signed 64-bit range');
} }
if (positive && kopecks <= 0n) { if (positive && kopecks <= ZERO_KOPECKS) {
throw new RangeError('Operation amount must be positive'); throw new RangeError('Operation amount must be positive');
} }
return kopecks; return kopecks;
@@ -30,6 +34,6 @@ export function parseRubMoney(money: Money, positive = false): bigint {
export function rubMoney(kopecks: bigint): Money { export function rubMoney(kopecks: bigint): Money {
return { return {
currency: 'RUB', currency: 'RUB',
value: formatDecimal({ value: kopecks, scale: 2 }), value: formatDecimal({ value: kopecks, scale: RUB_DECIMAL_PLACES }),
}; };
} }
@@ -9,6 +9,10 @@ import {
missing, missing,
} from '../application/bank-card-input'; } from '../application/bank-card-input';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
const INITIAL_RETRY_COUNT = 0;
const LAST_SEED_OFFSET = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_MAX_WAIT_MS = 10_000;
const TRANSACTION_TIMEOUT_MS = 15_000; const TRANSACTION_TIMEOUT_MS = 15_000;
@@ -28,7 +32,7 @@ const seeds = [
export class BankCardsRepository { export class BankCardsRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> { async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) { for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
try { try {
return await this.prisma.$transaction(run, { return await this.prisma.$transaction(run, {
maxWait: TRANSACTION_MAX_WAIT_MS, maxWait: TRANSACTION_MAX_WAIT_MS,
@@ -69,7 +73,7 @@ export class BankCardsRepository {
async seed(tx: Tx, userId: string) { async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) { for (const [index, label] of seeds.entries()) {
const seedKey = index === seeds.length - 1 ? 'other' : String(index); const seedKey = index === seeds.length - LAST_SEED_OFFSET ? 'other' : String(index);
if ( if (
!(await tx.expenseCategory.findUnique({ !(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } }, where: { userId_seedKey: { userId, seedKey } },
@@ -25,12 +25,18 @@ import {
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import { import {
FIRST_PAGE,
MAX_CARD_NAME_LENGTH, MAX_CARD_NAME_LENGTH,
MAX_CATEGORY_NAME_LENGTH, MAX_CATEGORY_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH,
MIN_CARD_NAME_LENGTH,
MIN_CATEGORY_NAME_LENGTH,
MIN_ENTITY_VERSION,
} from '../domain/bank-card-limits'; } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
const MIN_PAGE_SIZE = 1;
export const DEFAULT_BANK_PAGE_SIZE = 50; export const DEFAULT_BANK_PAGE_SIZE = 50;
const MAX_BANK_PAGE_SIZE = 100; const MAX_BANK_PAGE_SIZE = 100;
@@ -48,10 +54,10 @@ export class RubMoneyInputDto {
} }
export class CreateBankCardDto { export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH }) @ApiProperty({ minLength: MIN_CARD_NAME_LENGTH, maxLength: MAX_CARD_NAME_LENGTH })
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MinLength(1) @MinLength(MIN_CARD_NAME_LENGTH)
@MaxLength(MAX_CARD_NAME_LENGTH) @MaxLength(MAX_CARD_NAME_LENGTH)
declare name: string; declare name: string;
@@ -91,22 +97,22 @@ export class UpdateBankCardDto extends PartialType(
OmitType(CreateBankCardDto, ['id'] as const), OmitType(CreateBankCardDto, ['id'] as const),
{ skipNullProperties: false }, { skipNullProperties: false },
) { ) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsBoolean() @IsBoolean()
archived?: boolean; archived?: boolean;
} }
export class CategoryNameDto { export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH }) @ApiProperty({ minLength: MIN_CATEGORY_NAME_LENGTH, maxLength: MAX_CATEGORY_NAME_LENGTH })
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MinLength(1) @MinLength(MIN_CATEGORY_NAME_LENGTH)
@MaxLength(MAX_CATEGORY_NAME_LENGTH) @MaxLength(MAX_CATEGORY_NAME_LENGTH)
declare name: string; declare name: string;
} }
export class UpdateExpenseCategoryDto extends CategoryNameDto { export class UpdateExpenseCategoryDto extends CategoryNameDto {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
} }
export class CreateBankOperationDto { export class CreateBankOperationDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@@ -151,7 +157,7 @@ export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const), OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false }, { skipNullProperties: false },
) { ) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
} }
export class BankCardsQueryDto { export class BankCardsQueryDto {
@ApiPropertyOptional({ enum: ['true', 'false'] }) @ApiPropertyOptional({ enum: ['true', 'false'] })
@@ -160,22 +166,22 @@ export class BankCardsQueryDto {
includeArchived?: string; includeArchived?: string;
} }
export class BankPageQueryDto { export class BankPageQueryDto {
@ApiPropertyOptional({ minimum: 1, default: 1 }) @ApiPropertyOptional({ minimum: FIRST_PAGE, default: FIRST_PAGE })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(FIRST_PAGE)
page?: number; page?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
minimum: 1, minimum: MIN_PAGE_SIZE,
maximum: MAX_BANK_PAGE_SIZE, maximum: MAX_BANK_PAGE_SIZE,
default: DEFAULT_BANK_PAGE_SIZE, default: DEFAULT_BANK_PAGE_SIZE,
}) })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(MIN_PAGE_SIZE)
@Max(MAX_BANK_PAGE_SIZE) @Max(MAX_BANK_PAGE_SIZE)
limit?: number; limit?: number;
} }
@@ -215,10 +221,10 @@ export class BankSummaryQueryDto extends BankPeriodQueryDto {
cardId?: string; cardId?: string;
} }
export class BankVersionQueryDto { export class BankVersionQueryDto {
@ApiProperty({ minimum: 1 }) @ApiProperty({ minimum: MIN_ENTITY_VERSION })
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(MIN_ENTITY_VERSION)
declare version: number; declare version: number;
} }
export class ExpenseCategoryDto { export class ExpenseCategoryDto {
@@ -30,6 +30,8 @@ import {
rangeBuckets, rangeBuckets,
} from './income-period'; } from './income-period';
const STALE_FRESHNESS_MS = 0;
type Total = { type Total = {
coupon: ReturnType<typeof decimalFromString>; coupon: ReturnType<typeof decimalFromString>;
dividend: ReturnType<typeof decimalFromString>; dividend: ReturnType<typeof decimalFromString>;
@@ -120,7 +122,7 @@ export class IncomeAnalyticsService {
fetchedAt: current.meta.lastSuccessfulAt ? fetchedAt: current.meta.lastSuccessfulAt ?
new Date(current.meta.lastSuccessfulAt) : new Date(current.meta.lastSuccessfulAt) :
null, null,
freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0, freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
work: work?.id ? { ...work, id: work.id } : null, work: work?.id ? { ...work, id: work.id } : null,
coverage: current.meta.coverage, coverage: current.meta.coverage,
dependencies, dependencies,
+21 -13
View File
@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { MILLISECONDS_PER_DAY } from '../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
import { import {
DateOnlyRange, DateOnlyRange,
dateParts, dateParts,
@@ -8,6 +8,14 @@ import {
utcDate, utcDate,
} from '../investment-calendar'; } from '../investment-calendar';
const MONDAY_WEEKDAY_INDEX = 1;
const INCLUSIVE_DAY_OFFSET = 1;
const NEXT_DAY_OFFSET = 1;
const PREVIOUS_MONTH_LAST_DAY = 0;
const PREVIOUS_DAY_OFFSET = -1;
const PREVIOUS_YEAR_OFFSET = 1;
const DATE_COMPONENT_DIGITS = 2;
const DAILY_BUCKET_MAX_DAYS = 45; const DAILY_BUCKET_MAX_DAYS = 45;
const WEEKLY_BUCKET_MAX_DAYS = 180; const WEEKLY_BUCKET_MAX_DAYS = 180;
const DAYS_PER_WEEK = 7; const DAYS_PER_WEEK = 7;
@@ -65,21 +73,21 @@ export function rangeBuckets(
addDays( addDays(
range.from, range.from,
-( -(
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) % (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
DAYS_PER_WEEK DAYS_PER_WEEK
), ),
) : ) :
`${range.from.slice(0, ISO_MONTH_LENGTH)}-01`; `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
while (cursor <= range.to) { while (cursor <= range.to) {
const end = const end =
granularity === 'week' ? granularity === 'week' ?
addDays(cursor, DAYS_PER_WEEK - 1) : addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) :
endOfMonth(cursor); endOfMonth(cursor);
buckets.push({ buckets.push({
from: cursor < range.from ? range.from : cursor, from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end, to: end > range.to ? range.to : end,
}); });
cursor = addDays(end, 1); cursor = addDays(end, NEXT_DAY_OFFSET);
} }
return buckets; return buckets;
} }
@@ -89,7 +97,7 @@ export function rangeDays(range: DateOnlyRange): number {
Math.round( Math.round(
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) / (utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
MILLISECONDS_PER_DAY, MILLISECONDS_PER_DAY,
) + 1 ) + INCLUSIVE_DAY_OFFSET
); );
} }
@@ -106,24 +114,24 @@ function assertRange(range: DateOnlyRange, today: string): void {
function addDays(value: string, days: number): string { function addDays(value: string, days: number): string {
const date = utcDate(value); const date = utcDate(value);
date.setUTCDate(date.getUTCDate() + days); date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10); return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
function dates(range: DateOnlyRange): string[] { function dates(range: DateOnlyRange): string[] {
const result: string[] = []; const result: string[] = [];
for (let date = range.from; date <= range.to; date = addDays(date, 1)) { for (let date = range.from; date <= range.to; date = addDays(date, NEXT_DAY_OFFSET)) {
result.push(date); result.push(date);
} }
return result; return result;
} }
function firstOfMonth(value: string): string { function firstOfMonth(value: string): string {
return `${value.slice(0, ISO_MONTH_LENGTH)}-01`; return `${value.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
} }
function endOfMonth(value: string): string { function endOfMonth(value: string): string {
const [year, month] = dateParts(firstOfMonth(value)); const [year, month] = dateParts(firstOfMonth(value));
return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10); return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
function isFullMonth(range: DateOnlyRange): boolean { function isFullMonth(range: DateOnlyRange): boolean {
@@ -133,7 +141,7 @@ function isFullMonth(range: DateOnlyRange): boolean {
} }
function previousMonth(from: string): DateOnlyRange { function previousMonth(from: string): DateOnlyRange {
const last = addDays(firstOfMonth(from), -1); const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET);
return { from: firstOfMonth(last), to: last }; return { from: firstOfMonth(last), to: last };
} }
@@ -144,8 +152,8 @@ function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
function yearAgo(range: DateOnlyRange): DateOnlyRange | null { function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => { const shift = (value: string) => {
const [year, month, day] = dateParts(value); const [year, month, day] = dateParts(value);
const result = `${String(year - 1).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
return utcDate(result).toISOString().slice(0, 10) === result ? return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ?
result : result :
null; null;
}; };
@@ -0,0 +1,2 @@
export const MIN_INSTRUMENT_QUERY_LENGTH = 2;
export const MAX_INSTRUMENT_QUERY_LENGTH = 80;
@@ -27,6 +27,11 @@ import { CandleRepository } from '../infrastructure/candle.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { timestamp } from './instrument-market'; import { timestamp } from './instrument-market';
const FIRST_BATCH_OFFSET = 0;
const BACKGROUND_SYNC_PRIORITY = 2;
const REPLACED_WINDOW_COUNT = 1;
const NEXT_WINDOW_OFFSET = 1;
type CandleInput = { type CandleInput = {
instrumentType: 'bond' | 'share'; instrumentType: 'bond' | 'share';
period: 'all' | 'halfYear' | 'month' | 'year'; period: 'all' | 'halfYear' | 'month' | 'year';
@@ -246,7 +251,7 @@ export class CandleService {
) )
.map(([uid]) => uid); .map(([uid]) => uid);
for ( for (
let offset = 0; let offset = FIRST_BATCH_OFFSET;
offset < uids.length; offset < uids.length;
offset += prefetchReadBatchSize offset += prefetchReadBatchSize
) { ) {
@@ -297,7 +302,7 @@ export class CandleService {
now, now,
).length ).length
) { ) {
await this.request(userId, revision, input, now, false, 2); await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
} }
} }
} }
@@ -393,7 +398,7 @@ export class CandleService {
const gaps = candleGaps(window, latest.coverage, now) const gaps = candleGaps(window, latest.coverage, now)
.flatMap(candleWindows) .flatMap(candleWindows)
.reverse(); .reverse();
ranges.splice(index, 1, ...gaps); ranges.splice(index, REPLACED_WINDOW_COUNT, ...gaps);
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor. // eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
window = ranges[index]; window = ranges[index];
} }
@@ -430,7 +435,7 @@ export class CandleService {
) { ) {
return false; return false;
} }
pass.nextWindow = index + 1; pass.nextWindow = index + NEXT_WINDOW_OFFSET;
if (pass.nextWindow < ranges.length) return null; if (pass.nextWindow < ranges.length) return null;
} }
return true; return true;
@@ -512,7 +517,7 @@ export class CandleService {
{ ...producer.input, period: 'all' }, { ...producer.input, period: 'all' },
producer.now, producer.now,
false, false,
2, BACKGROUND_SYNC_PRIORITY,
); );
} }
} }
@@ -26,6 +26,10 @@ import {
selectCatalogInstruments, selectCatalogInstruments,
} from './catalog-selection'; } from './catalog-selection';
const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0;
const tickerAmbiguityLimit = 2; const tickerAmbiguityLimit = 2;
type Producer = { type Producer = {
@@ -160,7 +164,7 @@ export class CatalogDiscoveryService {
{ instrumentType, query: instrument.ticker, exactTicker: true }, { instrumentType, query: instrument.ticker, exactTicker: true },
observe, observe,
); );
if (alias.data?.length === 1 && alias.data[0].uid === uid) { if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase(); slug = instrument.ticker.toUpperCase();
} }
dependencies.push(alias.meta); dependencies.push(alias.meta);
@@ -200,8 +204,8 @@ export class CatalogDiscoveryService {
{ instrumentType, query: ticker, exactTicker: true }, { instrumentType, query: ticker, exactTicker: true },
observe, observe,
); );
if (lookup.data?.length === 0) throw new NotFoundException(); if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
if (lookup.data && lookup.data.length > 1) { if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({ throw new ConflictException({
code: code:
instrumentType === 'share' ? instrumentType === 'share' ?
@@ -346,7 +350,7 @@ export class CatalogDiscoveryService {
input.query, input.query,
input.instrumentType, input.instrumentType,
).slice( ).slice(
0, FIRST_RESULT_OFFSET,
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
), ),
new Date(), new Date(),
@@ -364,7 +368,7 @@ export class CatalogDiscoveryService {
if (success) { if (success) {
const saved = await this.repository.read(input); const saved = await this.repository.read(input);
for (const observer of [attempt, ...producer.observers]) { for (const observer of [attempt, ...producer.observers]) {
if (!input.exactTicker || saved.data?.length === 1) { if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) {
await this.enrichment.requestMany( await this.enrichment.requestMany(
observer.userId, observer.userId,
observer.revision, observer.revision,
@@ -39,6 +39,10 @@ import {
import { catalogSearchLimit } from './catalog-selection'; import { catalogSearchLimit } from './catalog-selection';
import { previousClose, timestamp } from './instrument-market'; import { previousClose, timestamp } from './instrument-market';
const MISSING_DATA_REVISION = 0;
const FIRST_QUEUE_INDEX = 0;
const DEQUEUED_ITEM_COUNT = 1;
type Producer = { type Producer = {
attempt: TbankSyncAttempt; attempt: TbankSyncAttempt;
input: CatalogSource; input: CatalogSource;
@@ -147,7 +151,7 @@ export class CatalogEnrichmentService {
price: payload?.price ?? null, price: payload?.price ?? null,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: catalogScope(input), resourceKey: catalogScope(input),
dataRevision: String(row?.dataRevision ?? 0), dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
availability: row ? 'complete' : 'missing', availability: row ? 'complete' : 'missing',
fetchedAt: row?.fetchedAt ?? null, fetchedAt: row?.fetchedAt ?? null,
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)], freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
@@ -485,7 +489,7 @@ export class CatalogEnrichmentService {
// One batch RPC for queued prices sharing the same authorized producer. // One batch RPC for queued prices sharing the same authorized producer.
if (producer.input.kind === 'last-price') { if (producer.input.kind === 'last-price') {
for ( for (
let i = 0; let i = FIRST_QUEUE_INDEX;
i < this.queue.length && batch.length < catalogSearchLimit; i < this.queue.length && batch.length < catalogSearchLimit;
) { ) {
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
@@ -496,7 +500,7 @@ export class CatalogEnrichmentService {
next.attempt.revision === producer.attempt.revision next.attempt.revision === producer.attempt.revision
) { ) {
batch.push(next); batch.push(next);
this.queue.splice(i, 1); this.queue.splice(i, DEQUEUED_ITEM_COUNT);
} else i++; } else i++;
} }
} }
@@ -3,19 +3,21 @@ import type {
TbankTimestamp, TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time';
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
const MOSCOW_OFFSET_MS = 10_800_000; const MOSCOW_OFFSET_MS = 10_800_000;
export function timestamp(value?: TbankTimestamp): null | string { export function timestamp(value?: TbankTimestamp): null | string {
const seconds = Number(value?.seconds); const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0 ? return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ?
new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() :
null; null;
} }
export function moscowDay(iso: string): string { export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
.toISOString() .toISOString()
.slice(0, 10); .slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
export function previousClose( export function previousClose(
candles: TbankCandle[], candles: TbankCandle[],
@@ -29,7 +31,7 @@ export function previousClose(
candle.isComplete && candle.isComplete &&
candle.close && candle.close &&
time && time &&
time.slice(0, 10) < moscowDay(priceAt) time.slice(ISO_DATE_START, ISO_DATE_LENGTH) < moscowDay(priceAt)
); );
}) })
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
@@ -12,6 +12,10 @@ import {
} from '../../portfolio/portfolio.api'; } from '../../portfolio/portfolio.api';
import { StockPositionDto } from '../stocks/presentation/stock.dto'; import { StockPositionDto } from '../stocks/presentation/stock.dto';
const EMPTY_POSITIONS_COUNT = 0;
const ZERO_QUANTITY = 0n;
const ZERO_AMOUNT = 0n;
const PRICE_DECIMAL_PLACES = 9; const PRICE_DECIMAL_PLACES = 9;
const RETURN_PERCENT_DECIMAL_PLACES = 6; const RETURN_PERCENT_DECIMAL_PLACES = 6;
@@ -32,8 +36,8 @@ export function instrumentPosition(
const currentValue = sumComplete(positions.map((p) => p.currentValue)); const currentValue = sumComplete(positions.map((p) => p.currentValue));
const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn)); const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn));
const positive = const positive =
positions.length > 0 && positions.length > EMPTY_POSITIONS_COUNT &&
positions.every((p) => decimalFromString(p.quantity).value > 0n); positions.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY);
return { return {
quantity: formatDecimal(quantity), quantity: formatDecimal(quantity),
currentValue, currentValue,
@@ -55,7 +59,7 @@ export function instrumentPosition(
accounts: positions.map((p) => ({ accounts: positions.map((p) => ({
...p, ...p,
returnPercent: returnPercent:
decimalFromString(p.quantity).value > 0n ? decimalFromString(p.quantity).value > ZERO_QUANTITY ?
percent(p.absoluteReturn, cost(p)) : percent(p.absoluteReturn, cost(p)) :
null, null,
})), })),
@@ -103,7 +107,7 @@ function percent(
!base || !base ||
!result || !result ||
base.currency !== result.currency || base.currency !== result.currency ||
decimalFromString(base.value).value <= 0n decimalFromString(base.value).value <= ZERO_AMOUNT
) { ) {
return null; return null;
} }
@@ -14,8 +14,11 @@ import {
type IdentifiedBond = TbankBond & { uid: string }; type IdentifiedBond = TbankBond & { uid: string };
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { money } from '../infrastructure/bond-money.mapper'; import { money } from '../infrastructure/bond-money.mapper';
const MIN_LOT_EXCLUSIVE = 0;
@Injectable() @Injectable()
export class BondCatalogService { export class BondCatalogService {
constructor(private readonly discovery: CatalogDiscoveryService) {} constructor(private readonly discovery: CatalogDiscoveryService) {}
@@ -100,13 +103,13 @@ function toBond(bond: IdentifiedBond): BondDto {
currency: bond.currency?.toUpperCase() || null, currency: bond.currency?.toUpperCase() || null,
bondType: bond.bondType || null, bondType: bond.bondType || null,
sector: bond.sector || null, sector: bond.sector || null,
lot: bond.lot && bond.lot > 0 ? bond.lot : null, lot: bond.lot && bond.lot > MIN_LOT_EXCLUSIVE ? bond.lot : null,
historyFrom: timestamp(bond.first_1dayCandleDate), historyFrom: timestamp(bond.first_1dayCandleDate),
nominal: money(bond.nominal), nominal: money(bond.nominal),
initialNominal: money(bond.initialNominal), initialNominal: money(bond.initialNominal),
aci: money(bond.aciValue), aci: money(bond.aciValue),
maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null, maturityDate: timestamp(bond.maturityDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null, callDate: timestamp(bond.callDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
couponQuantityPerYear: bond.couponQuantityPerYear ?? null, couponQuantityPerYear: bond.couponQuantityPerYear ?? null,
floatingCoupon: bond.floatingCouponFlag ?? null, floatingCoupon: bond.floatingCouponFlag ?? null,
perpetual: bond.perpetualFlag ?? null, perpetual: bond.perpetualFlag ?? null,
@@ -6,6 +6,7 @@ import {
formatDecimal, formatDecimal,
multiply, multiply,
} from '../../../../../common/decimal'; } from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { import {
TbankAccessService, TbankAccessService,
type TbankBond, type TbankBond,
@@ -30,6 +31,12 @@ import {
BondPositionDto, BondPositionDto,
} from '../presentation/bond.dto'; } from '../presentation/bond.dto';
import { bondQuote } from './bond-market.service'; import { bondQuote } from './bond-market.service';
const INCOME_FORECAST_YEARS = 1;
const ZERO_QUANTITY = 0n;
const MIN_COUPON_PERIOD_EXCLUSIVE = 0;
const ZERO_AMOUNT = 0n;
const ISO_YEAR_LENGTH = 4; const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5; const INCOME_HISTORY_YEARS = 5;
const COUPON_YIELD_DECIMAL_PLACES = 4; const COUPON_YIELD_DECIMAL_PLACES = 4;
@@ -77,8 +84,8 @@ export class BondIncomeService {
): Promise<BondCouponsResourceDto> { ): Promise<BondCouponsResourceDto> {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe); const portfolio = await this.portfolios.read(userId, 'all', observe);
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
@@ -139,13 +146,13 @@ export function bondCoupons(
const { from, to, today } = window; const { from, to, today } = window;
const quantity = const quantity =
position?.accounts.length && position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
decimalFromString(position.quantity) : decimalFromString(position.quantity) :
null; null;
const coupons = events const coupons = events
.map((event) => { .map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const perBond = money(event.payOneBond); const perBond = money(event.payOneBond);
const timing = !paymentDate ? const timing = !paymentDate ?
('undated' as const) : ('undated' as const) :
@@ -198,10 +205,10 @@ export function bondMetrics(
const effectiveYield = values.effectiveYield ?? null; const effectiveYield = values.effectiveYield ?? null;
const creditRating = values.creditRating ?? null; const creditRating = values.creditRating ?? null;
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const next = events const next = events
?.filter((event) => { ?.filter((event) => {
const date = timestamp(event.couponDate)?.slice(0, 10); const date = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH);
return date && date >= today && date <= to; return date && date >= today && date <= to;
}) })
.sort( .sort(
@@ -210,15 +217,15 @@ export function bondMetrics(
const coupon = money(next?.payOneBond); const coupon = money(next?.payOneBond);
const period = next?.couponPeriod; const period = next?.couponPeriod;
const couponPeriodDays = const couponPeriodDays =
period && Number.isInteger(period) && period > 0 ? period : null; period && Number.isInteger(period) && period > MIN_COUPON_PERIOD_EXCLUSIVE ? period : null;
const clean = quote?.cleanPrice; const clean = quote?.cleanPrice;
const couponYieldPercent = const couponYieldPercent =
coupon && coupon &&
couponPeriodDays && couponPeriodDays &&
clean && clean &&
coupon.currency === clean.currency && coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= 0n && decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
decimalFromString(clean.value).value > 0n ? decimalFromString(clean.value).value > ZERO_AMOUNT ?
formatDecimal( formatDecimal(
divide( divide(
multiply( multiply(
@@ -242,7 +249,7 @@ export function bondMetrics(
return { return {
creditRating, creditRating,
coupon, coupon,
couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null, couponDate: timestamp(next?.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
couponType, couponType,
couponPeriodDays, couponPeriodDays,
couponYieldPercent, couponYieldPercent,
@@ -10,6 +10,7 @@ import {
multiply, multiply,
subtract, subtract,
} from '../../../../../common/decimal'; } from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service'; import { CandleService } from '../../application/candle.service';
import { import {
@@ -24,6 +25,8 @@ import {
BondQuoteResourceDto, BondQuoteResourceDto,
} from '../presentation/bond.dto'; } from '../presentation/bond.dto';
const ZERO_AMOUNT = 0n;
const PRICE_DECIMAL_PLACES = 9; const PRICE_DECIMAL_PLACES = 9;
@Injectable() @Injectable()
export class BondMarketService { export class BondMarketService {
@@ -80,7 +83,7 @@ export function bondQuote(
const cleanPrice = const cleanPrice =
pricePercent !== null && pricePercent !== null &&
nominal && nominal &&
decimalFromString(nominal.value).value > 0n ? decimalFromString(nominal.value).value > ZERO_AMOUNT ?
{ {
currency: nominal.currency, currency: nominal.currency,
value: formatDecimal( value: formatDecimal(
@@ -118,7 +121,7 @@ export function bondQuote(
last?.price && previous?.close ? last?.price && previous?.close ?
formatDecimal(subtract(decimal(last.price), decimal(previous.close))) : formatDecimal(subtract(decimal(last.price), decimal(previous.close))) :
null, null,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
unavailable, unavailable,
observedAt, observedAt,
}; };
@@ -1,5 +1,7 @@
import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { MILLISECONDS_PER_DAY } from '../../../../common/time';
const FIRST_RANGE_END_INDEX = 1;
export type CandleRange = { from: Date; to: Date }; export type CandleRange = { from: Date; to: Date };
export type CandleCoverage = CandleRange & { export type CandleCoverage = CandleRange & {
fetchedAt: Date; fetchedAt: Date;
@@ -80,7 +82,7 @@ export function confirmedCandleCoverage(
} }
} }
const sorted = [...boundaries].sort((a, b) => a - b); const sorted = [...boundaries].sort((a, b) => a - b);
return sorted.slice(1).map((to, index) => { return sorted.slice(FIRST_RANGE_END_INDEX).map((to, index) => {
// eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array. // eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array.
const from = sorted[index]; const from = sorted[index];
return { return {
@@ -11,6 +11,8 @@ import {
type SavedCandle, type SavedCandle,
} from '../domain/candle-coverage'; } from '../domain/candle-coverage';
const NO_UPDATED_ROWS = 0;
const provider = 'tbank'; const provider = 'tbank';
const interval = 'day'; const interval = 'day';
type CandleIntent = { type CandleIntent = {
@@ -323,7 +325,7 @@ export class CandleRepository {
}, },
data: { state: 'complete', errorCode: null }, data: { state: 'complete', errorCode: null },
}); });
return result.count > 0; return result.count > NO_UPDATED_ROWS;
}); });
} }
@@ -11,6 +11,9 @@ import type {
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
const UNCHANGED_REVISION_INCREMENT = 0;
const REVISION_INCREMENT = 1;
export type CatalogQuery = { export type CatalogQuery = {
exactTicker?: boolean; exactTicker?: boolean;
instrumentType: 'bond' | 'share'; instrumentType: 'bond' | 'share';
@@ -205,7 +208,7 @@ export class CatalogDiscoveryRepository {
ticker: instrument.ticker ?? null, ticker: instrument.ticker ?? null,
fetchedAt: now, fetchedAt: now,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous.metadata, metadata) ? 0 : 1, increment: isDeepStrictEqual(previous.metadata, metadata) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -230,8 +233,8 @@ export class CatalogDiscoveryRepository {
previousAlias?.matchedUids, previousAlias?.matchedUids,
matchedUids, matchedUids,
) ? ) ?
0 : UNCHANGED_REVISION_INCREMENT :
1, REVISION_INCREMENT,
}, },
}, },
}); });
@@ -257,7 +260,7 @@ export class CatalogDiscoveryRepository {
payload, payload,
fetchedAt: now, fetchedAt: now,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1, increment: isDeepStrictEqual(previous?.payload, payload) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -390,7 +393,7 @@ export class CatalogDiscoveryRepository {
payload, payload,
fetchedAt: now, fetchedAt: now,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1, increment: isDeepStrictEqual(previous?.payload, payload) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -416,8 +419,8 @@ export class CatalogDiscoveryRepository {
previous?.matchedUids, previous?.matchedUids,
instrumentUids, instrumentUids,
) ? ) ?
0 : UNCHANGED_REVISION_INCREMENT :
1, REVISION_INCREMENT,
}, },
}, },
}); });
@@ -442,8 +445,8 @@ export class CatalogDiscoveryRepository {
previous?.instrumentUids, previous?.instrumentUids,
instrumentUids, instrumentUids,
) ? ) ?
0 : UNCHANGED_REVISION_INCREMENT :
1, REVISION_INCREMENT,
}, },
}, },
}); });
@@ -12,6 +12,8 @@ import {
StockSearchResourceDto, StockSearchResourceDto,
} from '../presentation/stock.dto'; } from '../presentation/stock.dto';
const MIN_LOT_EXCLUSIVE = 0;
type IdentifiedShare = TbankShare & { uid: string }; type IdentifiedShare = TbankShare & { uid: string };
@Injectable() @Injectable()
@@ -102,7 +104,7 @@ function stock(share: IdentifiedShare): StockDto {
currency: share.currency?.toUpperCase() || null, currency: share.currency?.toUpperCase() || null,
shareType: share.shareType || null, shareType: share.shareType || null,
sector: share.sector || null, sector: share.sector || null,
lot: share.lot && share.lot > 0 ? share.lot : null, lot: share.lot && share.lot > MIN_LOT_EXCLUSIVE ? share.lot : null,
historyFrom: timestamp(share.first_1dayCandleDate), historyFrom: timestamp(share.first_1dayCandleDate),
}; };
} }
@@ -5,6 +5,7 @@ import {
formatDecimal, formatDecimal,
multiply, multiply,
} from '../../../../../common/decimal'; } from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal';
import { import {
TbankAccessService, TbankAccessService,
@@ -23,6 +24,10 @@ import {
StockDividendsResourceDto, StockDividendsResourceDto,
StockPositionDto, StockPositionDto,
} from '../presentation/stock.dto'; } from '../presentation/stock.dto';
const INCOME_FORECAST_YEARS = 1;
const ZERO_QUANTITY = 0n;
const ISO_YEAR_LENGTH = 4; const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5; const INCOME_HISTORY_YEARS = 5;
@Injectable() @Injectable()
@@ -49,8 +54,8 @@ export class StockIncomeService {
): Promise<StockDividendsResourceDto> { ): Promise<StockDividendsResourceDto> {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe); const portfolio = await this.portfolios.read(userId, 'all', observe);
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
@@ -112,15 +117,15 @@ export function stockDividends(
const quantity = const quantity =
position && position &&
position.accounts.length && position.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
decimalFromString(position.quantity) : decimalFromString(position.quantity) :
null; null;
const dividends: StockDividendDto[] = events const dividends: StockDividendDto[] = events
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
.map((event) => { .map((event) => {
const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null; const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const paymentDate = timestamp(event.paymentDate)?.slice(0, 10) ?? null; const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const currency = event.dividendNet?.currency?.toUpperCase(); const currency = event.dividendNet?.currency?.toUpperCase();
const perShare = const perShare =
currency && event.dividendNet ? currency && event.dividendNet ?
@@ -7,6 +7,7 @@ import {
multiply, multiply,
subtract, subtract,
} from '../../../../../common/decimal'; } from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service'; import { CandleService } from '../../application/candle.service';
import { import {
@@ -20,6 +21,8 @@ import {
StockQuoteResourceDto, StockQuoteResourceDto,
} from '../presentation/stock.dto'; } from '../presentation/stock.dto';
const ZERO_AMOUNT = 0n;
const CHANGE_PERCENT_DECIMAL_PLACES = 6; const CHANGE_PERCENT_DECIMAL_PLACES = 6;
@Injectable() @Injectable()
export class StockMarketService { export class StockMarketService {
@@ -90,7 +93,7 @@ export function stockQuote(
price, price,
priceAt, priceAt,
tradingStatus, tradingStatus,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
previousClose: previousPrice, previousClose: previousPrice,
change: change:
difference && currency ? difference && currency ?
@@ -99,7 +102,7 @@ export function stockQuote(
changePercent: changePercent:
difference && difference &&
previousPrice && previousPrice &&
decimalFromString(previousPrice.value).value > 0n ? decimalFromString(previousPrice.value).value > ZERO_AMOUNT ?
formatDecimal( formatDecimal(
divide( divide(
multiply(difference, decimalFromString('100')), multiply(difference, decimalFromString('100')),
@@ -10,32 +10,36 @@ import {
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '../../../../../common/money.dto'; import { MoneyDto } from '../../../../../common/money.dto';
import {
MAX_INSTRUMENT_QUERY_LENGTH,
MIN_INSTRUMENT_QUERY_LENGTH,
} from '../../../instrument-query';
import { import {
InvestmentReadQueryDto, InvestmentReadQueryDto,
InvestmentResourceMetaDto, InvestmentResourceMetaDto,
} from '../../../investment-resource.dto'; } from '../../../investment-resource.dto';
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
const MAX_INSTRUMENT_QUERY_LENGTH = 80; const MIN_TICKER_LENGTH = 1;
export class StockSearchQueryDto extends InvestmentReadQueryDto { export class StockSearchQueryDto extends InvestmentReadQueryDto {
@ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) @ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
@Transform(({ value }: { value: unknown }) => @Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value, typeof value === 'string' ? value.trim() : value,
) )
@IsString() @IsString()
@MinLength(2) @MinLength(MIN_INSTRUMENT_QUERY_LENGTH)
@MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
declare query: string; declare query: string;
} }
export class StockTickerParamsDto { export class StockTickerParamsDto {
@ApiProperty({ @ApiProperty({
minLength: 1, minLength: MIN_TICKER_LENGTH,
maxLength: MAX_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH,
pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$', pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$',
}) })
@IsString() @IsString()
@MinLength(1) @MinLength(MIN_TICKER_LENGTH)
@MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
@Matches(/^[A-Z0-9][\w.@+-]*$/i) @Matches(/^[A-Z0-9][\w.@+-]*$/i)
declare ticker: string; declare ticker: string;
@@ -1,3 +1,8 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
const UTC_MONTH_INDEX_OFFSET = 1;
const NEXT_DAY_OFFSET = 1;
const MOSCOW_UTC_OFFSET_HOURS = 3; const MOSCOW_UTC_OFFSET_HOURS = 3;
export type DateOnlyRange = { from: string; to: string }; export type DateOnlyRange = { from: string; to: string };
const moscow = 'Europe/Moscow'; const moscow = 'Europe/Moscow';
@@ -21,11 +26,11 @@ export function dateOnlyBounds(
const from = dateParts(range.from); const from = dateParts(range.from);
const end = dateParts(range.to); const end = dateParts(range.to);
const to = new Date( const to = new Date(
Date.UTC(end[0], end[1] - 1, end[2] + 1, -MOSCOW_UTC_OFFSET_HOURS), Date.UTC(end[0], end[1] - UTC_MONTH_INDEX_OFFSET, end[2] + NEXT_DAY_OFFSET, -MOSCOW_UTC_OFFSET_HOURS),
); );
return { return {
from: new Date( from: new Date(
Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS), Date.UTC(from[0], from[1] - UTC_MONTH_INDEX_OFFSET, from[2], -MOSCOW_UTC_OFFSET_HOURS),
), ),
to: to > now ? now : to, to: to > now ? now : to,
}; };
@@ -39,7 +44,7 @@ export function dateParts(value: string): [number, number, number] {
number, number,
number, number,
]; ];
if (utcDate(value).toISOString().slice(0, 10) !== value) { if (utcDate(value).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== value) {
throw new Error('Invalid date'); throw new Error('Invalid date');
} }
return parts; return parts;
@@ -49,6 +54,6 @@ export function utcDate(value: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return new Date(NaN); if (!match) return new Date(NaN);
return new Date( return new Date(
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])), Date.UTC(Number(match[1]), Number(match[2]) - UTC_MONTH_INDEX_OFFSET, Number(match[3])),
); );
} }
@@ -1,3 +1,4 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
import { planIncomePeriod } from './income/income.api'; import { planIncomePeriod } from './income/income.api';
import { dateOnlyBounds, moscowDate } from './investment-calendar'; import { dateOnlyBounds, moscowDate } from './investment-calendar';
import { import {
@@ -5,6 +6,12 @@ import {
operationIntervals, operationIntervals,
} from './operations/domain/operations-sync-plan'; } from './operations/domain/operations-sync-plan';
const FORECAST_YEARS = 1;
const JANUARY_INDEX = 0;
const FIRST_DAY_OF_MONTH = 1;
const EXCLUSIVE_END_OFFSET_MS = 1;
const NO_END_OFFSET_MS = 0;
export type InvestmentScreen = { export type InvestmentScreen = {
accountId?: string; accountId?: string;
candlePeriod?: 'all' | 'halfYear' | 'month' | 'year'; candlePeriod?: 'all' | 'halfYear' | 'month' | 'year';
@@ -232,7 +239,7 @@ export function planInvestmentScreen(
const day = const day =
screen.screen === 'stock' || screen.screen === 'bond' ? screen.screen === 'stock' || screen.screen === 'bond' ?
moscowDate(now) : moscowDate(now) :
now.toISOString().slice(0, 10); now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
const from = const from =
screen.screen === 'events' && screen.from ? screen.screen === 'events' && screen.from ?
new Date(`${screen.from}T00:00:00Z`) : new Date(`${screen.from}T00:00:00Z`) :
@@ -250,7 +257,7 @@ export function planInvestmentScreen(
} }
if (screen.screen === 'stock' || screen.screen === 'bond') { if (screen.screen === 'stock' || screen.screen === 'bond') {
from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears); from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears);
to.setUTCFullYear(to.getUTCFullYear() + 1); to.setUTCFullYear(to.getUTCFullYear() + FORECAST_YEARS);
} }
sources.push({ sources.push({
...identity, ...identity,
@@ -325,7 +332,7 @@ export function ledgerRange(
new Date(fromValue) : new Date(fromValue) :
instrumentUid ? instrumentUid ?
new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') : new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') :
new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH));
const end = toValue ? const end = toValue ?
new Date( new Date(
/^\d{4}-\d{2}-\d{2}$/.test(toValue) ? /^\d{4}-\d{2}-\d{2}$/.test(toValue) ?
@@ -335,6 +342,6 @@ export function ledgerRange(
now; now;
return { return {
from, from,
to: new Date(Math.min(end.valueOf() + (toValue ? 1 : 0), now.valueOf())), to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())),
}; };
} }
@@ -11,6 +11,7 @@ import { accountHistory } from '../../investment-sync-plan';
import { import {
type OperationInterval, type OperationInterval,
operationIntervals, operationIntervals,
operationsSyncPriority,
planOperationRead, planOperationRead,
uncoveredOperationIntervals, uncoveredOperationIntervals,
} from '../domain/operations-sync-plan'; } from '../domain/operations-sync-plan';
@@ -19,8 +20,9 @@ import {
type OperationAttempt, type OperationAttempt,
} from '../infrastructure/brokerage-operations.repository'; } from '../infrastructure/brokerage-operations.repository';
const UPSTREAM_RANGE_MARGIN_SECONDS = 1;
const operationsPageSize = 1000; const operationsPageSize = 1000;
const priorities = { screen: 1, history: 2 } as const;
@Injectable() @Injectable()
export class BrokerageOperationsSyncService { export class BrokerageOperationsSyncService {
@@ -129,7 +131,7 @@ export class BrokerageOperationsSyncService {
userId: string, userId: string,
intervals: OperationInterval[], intervals: OperationInterval[],
atomic: boolean, atomic: boolean,
priority: keyof typeof priorities = 'screen', priority: keyof typeof operationsSyncPriority = 'screen',
revision?: string, revision?: string,
): Promise<null | string> { ): Promise<null | string> {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
@@ -162,7 +164,7 @@ export class BrokerageOperationsSyncService {
atomic, atomic,
this.runningAttempts, this.runningAttempts,
// eslint-disable-next-line security/detect-object-injection -- The priority belongs to the internal priority union. // eslint-disable-next-line security/detect-object-injection -- The priority belongs to the internal priority union.
priorities[priority], operationsSyncPriority[priority],
); );
if (!requested) return null; if (!requested) return null;
const { id, attemptId, revision } = requested.work; const { id, attemptId, revision } = requested.work;
@@ -257,12 +259,12 @@ export class BrokerageOperationsSyncService {
// Upstream inclusivity is not assumed; storage selects exact [from,to). // Upstream inclusivity is not assumed; storage selects exact [from,to).
from: { from: {
seconds: String( seconds: String(
Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - 1, Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - UPSTREAM_RANGE_MARGIN_SECONDS,
), ),
}, },
to: { to: {
seconds: String( seconds: String(
Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1, Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + UPSTREAM_RANGE_MARGIN_SECONDS,
), ),
}, },
...(interval.instrumentUid ? ...(interval.instrumentUid ?
@@ -19,6 +19,7 @@ import {
} from '../../investment-sync-plan'; } from '../../investment-sync-plan';
import { import {
operationIntervalsOverlap, operationIntervalsOverlap,
operationsSyncPriority,
planOperationRead, planOperationRead,
uncoveredOperationIntervals, uncoveredOperationIntervals,
} from '../domain/operations-sync-plan'; } from '../domain/operations-sync-plan';
@@ -30,6 +31,14 @@ import {
} from '../presentation/brokerage-operation.dto'; } from '../presentation/brokerage-operation.dto';
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service'; import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
const EXCLUSIVE_END_OFFSET_MS = 1;
const FIRST_RESULT_OFFSET = 0;
const MISSING_MEMBERSHIP_REVISION = 0;
const STALE_FRESHNESS_MS = 0;
const UNKNOWN_AS_OF_MS = 0;
const LAST_ROW_INDEX = -1;
const UNIX_EPOCH_MS = 0;
type LocalOperationsCursor = { type LocalOperationsCursor = {
accountId: string; accountId: string;
at: string; at: string;
@@ -106,7 +115,7 @@ export class BrokerageOperationsService {
screen: 'operations', screen: 'operations',
accountId, accountId,
from: from.toISOString(), from: from.toISOString(),
to: new Date(to.valueOf() - 1).toISOString(), to: new Date(to.valueOf() - EXCLUSIVE_END_OFFSET_MS).toISOString(),
}, },
{ {
accounts: selected, accounts: selected,
@@ -244,7 +253,7 @@ export class BrokerageOperationsService {
const knownBoundary = accounts.every( const knownBoundary = accounts.every(
(account) => account.openedOn !== null, (account) => account.openedOn !== null,
); );
const rows = saved?.rows.slice(0, limit) ?? []; const rows = saved?.rows.slice(FIRST_RESULT_OFFSET, limit) ?? [];
const instruments = await this.metadata.savedMany( const instruments = await this.metadata.savedMany(
rows.map((row) => row.payload as TbankOperationItem), rows.map((row) => row.payload as TbankOperationItem),
); );
@@ -278,7 +287,7 @@ export class BrokerageOperationsService {
} }
const membershipMeta = investmentResourceMeta({ const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership', resourceKey: 'accounts/membership',
dataRevision: String(saved?.connection.membershipRevision ?? 0), dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
availability: available ? 'complete' : 'missing', availability: available ? 'complete' : 'missing',
fetchedAt: saved?.connection.membershipAt ?? null, fetchedAt: saved?.connection.membershipAt ?? null,
freshForMs: tbankSourceTtlMs.membership, freshForMs: tbankSourceTtlMs.membership,
@@ -296,7 +305,7 @@ export class BrokerageOperationsService {
'missing', 'missing',
fetchedAt: lastSuccessfulAt, fetchedAt: lastSuccessfulAt,
freshForMs: plan.intervals.some((interval) => interval.stale) ? freshForMs: plan.intervals.some((interval) => interval.stale) ?
0 : STALE_FRESHNESS_MS :
Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY,
work: work ?? saved?.membershipWork ?? null, work: work ?? saved?.membershipWork ?? null,
dependencies: [membershipMeta, ...metadataDependencies], dependencies: [membershipMeta, ...metadataDependencies],
@@ -321,7 +330,7 @@ export class BrokerageOperationsService {
(interval) => (interval) =>
interval.accountId === account.accountId, interval.accountId === account.accountId,
) )
.map((interval) => interval.asOf?.valueOf() ?? 0), .map((interval) => interval.asOf?.valueOf() ?? UNKNOWN_AS_OF_MS),
), ),
), ),
), ),
@@ -329,11 +338,11 @@ export class BrokerageOperationsService {
null, null,
historyBoundary: knownBoundary ? 'known' : 'unknown', historyBoundary: knownBoundary ? 'known' : 'unknown',
historyState: saved?.work.some( historyState: saved?.work.some(
(work) => work.state === 'running' && work.priority > 1, (work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
) ? ) ?
'loading' : 'loading' :
saved?.work.some( saved?.work.some(
(work) => work.state === 'failed' && work.priority > 1, (work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
) ? ) ?
'failed' : 'failed' :
knownBoundary && knownBoundary &&
@@ -346,7 +355,7 @@ export class BrokerageOperationsService {
'missing', 'missing',
}, },
}); });
const last = rows.at(-1); const last = rows.at(LAST_ROW_INDEX);
const hasNext = Boolean(saved && saved.rows.length > limit); const hasNext = Boolean(saved && saved.rows.length > limit);
return { return {
data: data:
@@ -448,7 +457,7 @@ export class BrokerageOperationsService {
userId, userId,
revision: connection.revision, revision: connection.revision,
accountId, accountId,
from: new Date(0), from: new Date(UNIX_EPOCH_MS),
to: new Date(), to: new Date(),
categories: ['income'], categories: ['income'],
filterKey: `income/${accountId}`, filterKey: `income/${accountId}`,
@@ -486,7 +495,7 @@ export class BrokerageOperationsService {
const now = new Date(); const now = new Date();
const membership = investmentResourceMeta({ const membership = investmentResourceMeta({
resourceKey: 'accounts/membership', resourceKey: 'accounts/membership',
dataRevision: String(saved?.connection.membershipRevision ?? 0), dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
availability: saved?.connection.membershipAt ? 'complete' : 'missing', availability: saved?.connection.membershipAt ? 'complete' : 'missing',
fetchedAt: saved?.connection.membershipAt ?? null, fetchedAt: saved?.connection.membershipAt ?? null,
freshForMs: tbankSourceTtlMs.membership, freshForMs: tbankSourceTtlMs.membership,
@@ -559,7 +568,7 @@ export class BrokerageOperationsService {
'partial' : 'partial' :
'missing', 'missing',
fetchedAt, fetchedAt,
freshForMs: plan.intervals.some((range) => range.stale) ? 0 : Infinity, freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
work, work,
dependencies: [membership], dependencies: [membership],
coverage: { coverage: {
@@ -1,3 +1,11 @@
export const operationsSyncPriority = { screen: 1, history: 2 } as const;
const LAST_INTERVAL_INDEX = -1;
const NEXT_MONTH_OFFSET = 1;
const FIRST_DAY_OF_MONTH = 1;
const EMPTY_INTERVAL_COUNT = 0;
const EMPTY_SOURCE_COUNT = 0;
export type OperationInterval = { export type OperationInterval = {
accountId: string; accountId: string;
from: Date; from: Date;
@@ -31,7 +39,7 @@ export function operationIntervals(
for (const interval of (groups.get(key) ?? []).sort( for (const interval of (groups.get(key) ?? []).sort(
(left, right) => left.from.valueOf() - right.from.valueOf(), (left, right) => left.from.valueOf() - right.from.valueOf(),
)) { )) {
const previous = merged.at(-1); const previous = merged.at(LAST_INTERVAL_INDEX);
if (previous && interval.from <= previous.to) { if (previous && interval.from <= previous.to) {
previous.to = new Date( previous.to = new Date(
Math.max(previous.to.valueOf(), interval.to.valueOf()), Math.max(previous.to.valueOf(), interval.to.valueOf()),
@@ -42,7 +50,7 @@ export function operationIntervals(
let from = interval.from; let from = interval.from;
while (from < interval.to) { while (from < interval.to) {
const nextMonth = new Date( const nextMonth = new Date(
Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + 1, 1), Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + NEXT_MONTH_OFFSET, FIRST_DAY_OF_MONTH),
); );
const to = new Date( const to = new Date(
Math.min(nextMonth.valueOf(), interval.to.valueOf()), Math.min(nextMonth.valueOf(), interval.to.valueOf()),
@@ -111,7 +119,7 @@ export function planOperationRead(
options: { force?: boolean; movingEnd?: boolean } = {}, options: { force?: boolean; movingEnd?: boolean } = {},
) { ) {
const monthStart = new Date( const monthStart = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), FIRST_DAY_OF_MONTH),
); );
const observedEnd = Math.max( const observedEnd = Math.max(
...requested.map((interval) => interval.to.valueOf()), ...requested.map((interval) => interval.to.valueOf()),
@@ -146,10 +154,10 @@ export function planOperationRead(
} }
} }
const missing = uncoveredOperationIntervals([effective], ranges); const missing = uncoveredOperationIntervals([effective], ranges);
const complete = missing.length === 0; const complete = missing.length === EMPTY_INTERVAL_COUNT;
const stale = const stale =
current && current &&
sources.length > 0 && sources.length > EMPTY_SOURCE_COUNT &&
uncoveredOperationIntervals( uncoveredOperationIntervals(
ranges ranges
.map((row) => ({ .map((row) => ({
@@ -161,7 +169,7 @@ export function planOperationRead(
})) }))
.filter((row) => row.from < row.to), .filter((row) => row.from < row.to),
fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })), fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })),
).length > 0; ).length > EMPTY_INTERVAL_COUNT;
if (options.force) load.push(target); if (options.force) load.push(target);
else if (stale) load.push({ ...target, from: monthStart, to: now }); else if (stale) load.push({ ...target, from: monthStart, to: now });
else load.push(...missing); else load.push(...missing);
@@ -10,6 +10,8 @@ import {
OperationCategory, OperationCategory,
} from '../presentation/brokerage-operation.dto'; } from '../presentation/brokerage-operation.dto';
const MISSING_TIMESTAMP_COMPONENT = 0;
const NANOSECONDS_PER_MILLISECOND = 1_000_000; const NANOSECONDS_PER_MILLISECOND = 1_000_000;
const types: Record<string, OperationCategory> = { const types: Record<string, OperationCategory> = {
@@ -89,7 +91,7 @@ export function operationTimestamp(
value: undefined | { nanos?: number; seconds?: number | string }, value: undefined | { nanos?: number; seconds?: number | string },
) { ) {
return new Date( return new Date(
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND + Number(value?.seconds ?? MISSING_TIMESTAMP_COMPONENT) * MILLISECONDS_PER_SECOND +
Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), Math.floor((value?.nanos ?? MISSING_TIMESTAMP_COMPONENT) / NANOSECONDS_PER_MILLISECOND),
); );
} }
@@ -22,12 +22,21 @@ import {
type OperationInterval, type OperationInterval,
operationIntervals, operationIntervals,
operationIntervalsOverlap, operationIntervalsOverlap,
operationsSyncPriority,
uncoveredOperationIntervals, uncoveredOperationIntervals,
} from '../domain/operations-sync-plan'; } from '../domain/operations-sync-plan';
import { import {
operationCategory, operationCategory,
operationTimestamp, operationTimestamp,
} from './brokerage-operation.mapper'; } from './brokerage-operation.mapper';
const CURSOR_LOOKAHEAD_ROWS = 1;
const EMPTY_INTERVAL_COUNT = 0;
const SINGLE_WORK_GROUP = 1;
const INITIAL_PAGE_COUNT = 0;
const PAGE_INCREMENT = 1;
const EXPECTED_UPDATED_WORK_ITEMS = 1;
export type { OperationInterval } from '../domain/operations-sync-plan'; export type { OperationInterval } from '../domain/operations-sync-plan';
export type OperationAttempt = { export type OperationAttempt = {
@@ -202,7 +211,7 @@ export class BrokerageOperationsRepository {
{ accountId: 'asc' }, { accountId: 'asc' },
{ brokerOperationId: 'asc' }, { brokerOperationId: 'asc' },
], ],
...(input.limit === undefined ? {} : { take: input.limit + 1 }), ...(input.limit === undefined ? {} : { take: input.limit + CURSOR_LOOKAHEAD_ROWS }),
}); });
return { return {
connection, connection,
@@ -280,7 +289,7 @@ export class BrokerageOperationsRepository {
) { ) {
throw new NotFoundException(); throw new NotFoundException();
} }
if (priority > 1) { if (priority > operationsSyncPriority.screen) {
for (const account of accounts.filter((account) => for (const account of accounts.filter((account) =>
intervals.some( intervals.some(
(interval) => interval.accountId === account.accountId, (interval) => interval.accountId === account.accountId,
@@ -331,7 +340,7 @@ export class BrokerageOperationsRepository {
to: row.to, to: row.to,
instrumentUid: interval.instrumentUid, instrumentUid: interval.instrumentUid,
})), })),
).length === 0, ).length === EMPTY_INTERVAL_COUNT,
); );
const active = await tx.tbankSyncWork.findMany({ const active = await tx.tbankSyncWork.findMany({
where: { where: {
@@ -366,7 +375,7 @@ export class BrokerageOperationsRepository {
const upgradeGroup = const upgradeGroup =
publicationAtomic && previousIntent && !previousIntent.atomic; publicationAtomic && previousIntent && !previousIntent.atomic;
if ( if (
touching.length <= 1 && touching.length <= SINGLE_WORK_GROUP &&
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === processRunId && previous.processRunId === processRunId &&
runningAttempts.has(previous.attemptId) && runningAttempts.has(previous.attemptId) &&
@@ -394,10 +403,10 @@ export class BrokerageOperationsRepository {
screenIntervals: [ screenIntervals: [
...new Set([ ...new Set([
...(previousIntent?.screenIntervals ?? ...(previousIntent?.screenIntervals ??
(previous.priority === 1 ? (previous.priority === operationsSyncPriority.screen ?
(previousIntent?.intervals ?? []) : (previousIntent?.intervals ?? []) :
[])), [])),
...(priority === 1 ? ...(priority === operationsSyncPriority.screen ?
[...pending, ...additions] [...pending, ...additions]
.filter((item) => .filter((item) =>
intervals.some((target) => intervals.some((target) =>
@@ -460,7 +469,7 @@ export class BrokerageOperationsRepository {
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
} }
const targets = const targets =
recovering && !upgradeGroup && touching.length <= 1 ? recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP ?
previous?.scopeKey === scopeKey ? previous?.scopeKey === scopeKey ?
retained : retained :
[...retained, ...uncoveredOperationIntervals(intervals, retained)] : [...retained, ...uncoveredOperationIntervals(intervals, retained)] :
@@ -491,13 +500,13 @@ export class BrokerageOperationsRepository {
screenIntervals: targets screenIntervals: targets
.filter( .filter(
(target) => (target) =>
(priority === 1 && (priority === operationsSyncPriority.screen &&
intervals.some((incoming) => intervals.some((incoming) =>
operationIntervalsOverlap(target, incoming), operationIntervalsOverlap(target, incoming),
)) || )) ||
( (
previousIntent?.screenIntervals ?? previousIntent?.screenIntervals ??
(previous?.priority === 1 ? (previous?.priority === operationsSyncPriority.screen ?
(previousIntent?.intervals ?? []) : (previousIntent?.intervals ?? []) :
[]) [])
).some((key) => ).some((key) =>
@@ -505,7 +514,7 @@ export class BrokerageOperationsRepository {
), ),
) )
.map(intervalKey), .map(intervalKey),
atomic: atomicTargets.length > 0, atomic: atomicTargets.length > EMPTY_INTERVAL_COUNT,
}; };
const data = { const data = {
intent, intent,
@@ -703,7 +712,7 @@ export class BrokerageOperationsRepository {
...(progress?.seen ?? []), ...(progress?.seen ?? []),
...(response.hasNext ? [nextCursor] : []), ...(response.hasNext ? [nextCursor] : []),
], ],
pages: (progress?.pages ?? 0) + 1, pages: (progress?.pages ?? INITIAL_PAGE_COUNT) + PAGE_INCREMENT,
}; };
await tx.tbankSyncStage.upsert({ await tx.tbankSyncStage.upsert({
where: { where: {
@@ -891,11 +900,11 @@ export class BrokerageOperationsRepository {
priority: remaining.some((key) => priority: remaining.some((key) =>
( (
intent.screenIntervals ?? intent.screenIntervals ??
(work.priority === 1 ? intent.intervals : []) (work.priority === operationsSyncPriority.screen ? intent.intervals : [])
).includes(key), ).includes(key),
) ? ) ?
1 : operationsSyncPriority.screen :
2, operationsSyncPriority.history,
progress: { remainingIntervals: remaining.length }, progress: { remainingIntervals: remaining.length },
}, },
}); });
@@ -932,7 +941,7 @@ async function claim(
}, },
data: { updatedAt: new Date() }, data: { updatedAt: new Date() },
}); });
return result.count === 1; return result.count === EXPECTED_UPDATED_WORK_ITEMS;
} }
function decodeInterval(key: string): OperationInterval { function decodeInterval(key: string): OperationInterval {
@@ -20,6 +20,8 @@ import {
InvestmentResourceMetaDto, InvestmentResourceMetaDto,
} from '../../investment-resource.dto'; } from '../../investment-resource.dto';
const MIN_OPERATIONS_PAGE_SIZE = 1;
const MAX_CURSOR_LENGTH = 4096; const MAX_CURSOR_LENGTH = 4096;
const MAX_OPERATIONS_PAGE_SIZE = 1000; const MAX_OPERATIONS_PAGE_SIZE = 1000;
export const DEFAULT_OPERATIONS_PAGE_SIZE = 100; export const DEFAULT_OPERATIONS_PAGE_SIZE = 100;
@@ -70,14 +72,14 @@ export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto {
cursor?: string; cursor?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
minimum: 1, minimum: MIN_OPERATIONS_PAGE_SIZE,
maximum: MAX_OPERATIONS_PAGE_SIZE, maximum: MAX_OPERATIONS_PAGE_SIZE,
default: DEFAULT_OPERATIONS_PAGE_SIZE, default: DEFAULT_OPERATIONS_PAGE_SIZE,
}) })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(MIN_OPERATIONS_PAGE_SIZE)
@Max(MAX_OPERATIONS_PAGE_SIZE) @Max(MAX_OPERATIONS_PAGE_SIZE)
limit?: number; limit?: number;
} }
@@ -9,6 +9,9 @@ import {
type ForecastSourceVersion, type ForecastSourceVersion,
} from '../infrastructure/forecast.repository'; } from '../infrastructure/forecast.repository';
const MISSING_DATA_REVISION = 0;
const STALE_FRESHNESS_MS = 0;
@Injectable() @Injectable()
export class ForecastService { export class ForecastService {
constructor(private readonly repository: ForecastRepository) {} constructor(private readonly repository: ForecastRepository) {}
@@ -89,7 +92,7 @@ export class ForecastService {
data, data,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: input.scopeKey, resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? 0), dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
availability: stored ? availability: stored ?
'complete' : 'complete' :
dependencies.some( dependencies.some(
@@ -100,7 +103,7 @@ export class ForecastService {
fetchedAt: first ? new Date(first) : null, fetchedAt: first ? new Date(first) : null,
freshForMs: freshForMs:
data && 'positionIsStale' in data && data.positionIsStale === true ? data && 'positionIsStale' in data && data.positionIsStale === true ?
0 : STALE_FRESHNESS_MS :
Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY,
work: work?.id ? { ...work, id: work.id } : null, work: work?.id ? { ...work, id: work.id } : null,
dependencies, dependencies,
@@ -43,6 +43,8 @@ import {
PortfolioPositionDto, PortfolioPositionDto,
} from '../presentation/portfolio.dto'; } from '../presentation/portfolio.dto';
const MISSING_DATA_REVISION = 0;
const membershipFreshForMs = tbankSourceTtlMs.membership; const membershipFreshForMs = tbankSourceTtlMs.membership;
const types: Record<string, string> = { const types: Record<string, string> = {
share: 'STOCK', share: 'STOCK',
@@ -167,7 +169,7 @@ export class PortfolioService {
} }
const membershipMeta = investmentResourceMeta({ const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership', resourceKey: 'accounts/membership',
dataRevision: String(saved?.membershipRevision ?? 0), dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
availability: saved?.membershipAt ? 'complete' : 'missing', availability: saved?.membershipAt ? 'complete' : 'missing',
fetchedAt: saved?.membershipAt ?? null, fetchedAt: saved?.membershipAt ?? null,
freshForMs: membershipFreshForMs, freshForMs: membershipFreshForMs,
@@ -195,7 +197,7 @@ export class PortfolioService {
null, null,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
dataRevision: String(account.portfolio?.dataRevision ?? 0), dataRevision: String(account.portfolio?.dataRevision ?? MISSING_DATA_REVISION),
availability: account.portfolio ? 'complete' : 'missing', availability: account.portfolio ? 'complete' : 'missing',
fetchedAt: account.portfolio?.fetchedAt ?? null, fetchedAt: account.portfolio?.fetchedAt ?? null,
freshForMs: portfolioFreshForMs, freshForMs: portfolioFreshForMs,
@@ -2,10 +2,16 @@ import type { Money } from '../../../../common/money.types';
import { import {
add, add,
DECIMAL_RADIX,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
} from '../../../../common/decimal'; } from '../../../../common/decimal';
const ZERO_AMOUNT = 0n;
const INITIAL_UNVALUED_COUNT = 0;
const POSITION_INCREMENT = 1;
const ROUNDING_DIVISOR = 2n;
const SHARE_PERCENT_DECIMAL_PLACES = 6; const SHARE_PERCENT_DECIMAL_PLACES = 6;
const PERCENT_SCALED_FACTOR = 100_000_000n; const PERCENT_SCALED_FACTOR = 100_000_000n;
@@ -33,7 +39,7 @@ export function portfolioStructure(
const value = decimalFromString(money.value); const value = decimalFromString(money.value);
// eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array.
group.values[index] = add(group.values[index], value); group.values[index] = add(group.values[index], value);
group.hasNegativeValues ||= value.value < 0n; group.hasNegativeValues ||= value.value < ZERO_AMOUNT;
currencies.set(money.currency, group); currencies.set(money.currency, group);
}; };
for (const position of positions) { for (const position of positions) {
@@ -41,7 +47,7 @@ export function portfolioStructure(
position.type : position.type :
'OTHER'; 'OTHER';
if (position.currentValue) include(type, position.currentValue); if (position.currentValue) include(type, position.currentValue);
else unvalued.set(type, (unvalued.get(type) ?? 0) + 1); else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT);
} }
for (const amount of cash) include('CASH', amount); for (const amount of cash) include('CASH', amount);
@@ -52,7 +58,7 @@ export function portfolioStructure(
.map(([currency, group]) => { .map(([currency, group]) => {
const total = group.values.reduce(add, decimalFromString('0')); const total = group.values.reduce(add, decimalFromString('0'));
const canCalculateShares = const canCalculateShares =
!unvalued.size && !group.hasNegativeValues && total.value > 0n; !unvalued.size && !group.hasNegativeValues && total.value > ZERO_AMOUNT;
const categories = group.values.map((value, index) => ({ const categories = group.values.map((value, index) => ({
// eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array.
type: categoryTypes[index], type: categoryTypes[index],
@@ -63,9 +69,9 @@ export function portfolioStructure(
formatDecimal({ formatDecimal({
value: value:
(value.value * (value.value *
10n ** BigInt(total.scale - value.scale) * DECIMAL_RADIX ** BigInt(total.scale - value.scale) *
PERCENT_SCALED_FACTOR + PERCENT_SCALED_FACTOR +
total.value / 2n) / total.value / ROUNDING_DIVISOR) /
total.value, total.value,
scale: SHARE_PERCENT_DECIMAL_PLACES, scale: SHARE_PERCENT_DECIMAL_PLACES,
}) : }) :
@@ -5,6 +5,8 @@ import type { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
const FIRST_BATCH_OFFSET = 0;
export type ForecastSourceVersion = { export type ForecastSourceVersion = {
dataRevision: number; dataRevision: number;
fetchedAt: Date; fetchedAt: Date;
@@ -82,7 +84,7 @@ export class ForecastRepository {
// Four identity parameters per source: stay below SQLite's parameter limit. // Four identity parameters per source: stay below SQLite's parameter limit.
const sources: ForecastSourceVersion[] = []; const sources: ForecastSourceVersion[] = [];
for ( for (
let offset = 0; let offset = FIRST_BATCH_OFFSET;
offset < input.sources.length; offset < input.sources.length;
offset += forecastSourceReadBatchSize offset += forecastSourceReadBatchSize
) { ) {
@@ -10,6 +10,8 @@ import {
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
const EXPECTED_UPDATED_ACCOUNTS = 1;
type PortfolioAttempt = { type PortfolioAttempt = {
attemptId: string; attemptId: string;
id: string; id: string;
@@ -152,7 +154,7 @@ export class PortfolioRepository {
}, },
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
}); });
return result.count === 1; return result.count === EXPECTED_UPDATED_ACCOUNTS;
} }
} }
@@ -2,6 +2,9 @@ import { BadRequestException } from '@nestjs/common';
import type { InvestmentScreenDto } from './tbank-sync.dto'; import type { InvestmentScreenDto } from './tbank-sync.dto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
import { MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
// Reject fields from other screens before reserving any source work. // Reject fields from other screens before reserving any source work.
export function normalizeScreen( export function normalizeScreen(
input: InvestmentScreenDto, input: InvestmentScreenDto,
@@ -61,7 +64,7 @@ export function normalizeScreen(
} }
if ( if (
['bonds', 'stocks'].includes(input.screen) && ['bonds', 'stocks'].includes(input.screen) &&
(!query || query.length < 2) (!query || query.length < MIN_INSTRUMENT_QUERY_LENGTH)
) { ) {
fail(); fail();
} }
@@ -81,7 +84,7 @@ export function normalizeScreen(
if ( if (
input.screen === 'events' && input.screen === 'events' &&
input.to && input.to &&
(input.from ?? new Date().toISOString().slice(0, 10)) > input.to (input.from ?? new Date().toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH)) > input.to
) { ) {
fail(); fail();
} }
@@ -21,11 +21,14 @@ import {
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { operationCategories } from '../operations/operations.api'; import { operationCategories } from '../operations/operations.api';
const MIN_ACCOUNT_ID_LENGTH = 1;
const MIN_SYNC_PAGE_SIZE = 1;
const maxAccountIdLength = 256; const maxAccountIdLength = 256;
const maxQueryLength = 80;
const maxPageSize = 1000; const maxPageSize = 1000;
export const investmentScreens = [ export const investmentScreens = [
@@ -51,7 +54,7 @@ export class InvestmentScreenDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(1) @MinLength(MIN_ACCOUNT_ID_LENGTH)
@MaxLength(maxAccountIdLength) @MaxLength(maxAccountIdLength)
accountId?: string; accountId?: string;
@@ -83,8 +86,8 @@ export class InvestmentScreenDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(2) @MinLength(MIN_INSTRUMENT_QUERY_LENGTH)
@MaxLength(maxQueryLength) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
query?: string; query?: string;
@ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] }) @ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] })
@@ -104,10 +107,10 @@ export class InvestmentScreenDto {
@IsIn(operationCategories, { each: true }) @IsIn(operationCategories, { each: true })
categories?: (typeof operationCategories)[number][]; categories?: (typeof operationCategories)[number][];
@ApiPropertyOptional({ minimum: 1, maximum: maxPageSize }) @ApiPropertyOptional({ minimum: MIN_SYNC_PAGE_SIZE, maximum: maxPageSize })
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1) @Min(MIN_SYNC_PAGE_SIZE)
@Max(maxPageSize) @Max(maxPageSize)
limit?: number; limit?: number;
} }
@@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto';
import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
import { import {
InstrumentMetadataService, InstrumentMetadataService,
TbankAccessService, TbankAccessService,
@@ -41,6 +42,11 @@ import {
TbankSyncRepository, TbankSyncRepository,
} from './tbank-sync.repository'; } from './tbank-sync.repository';
const FOREGROUND_CANDLE_PRIORITY = 0;
const EXCLUSIVE_END_OFFSET_MS = 1;
const PENDING_CHILD_COUNT = 0;
const COMPLETED_CHILD_COUNT = 1;
@Injectable() @Injectable()
export class TbankSyncService { export class TbankSyncService {
private readonly runId = randomUUID(); private readonly runId = randomUUID();
@@ -279,8 +285,8 @@ export class TbankSyncService {
'bond-events', 'bond-events',
], ],
{ {
from: source.from.toISOString().slice(0, 10), from: source.from.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH),
to: source.to.toISOString().slice(0, 10), to: source.to.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH),
ticker: ticker:
positions.find((position) => position.instrumentUid === uid) positions.find((position) => position.instrumentUid === uid)
?.ticker ?? undefined, ?.ticker ?? undefined,
@@ -312,7 +318,7 @@ export class TbankSyncService {
}, },
now, now,
true, true,
0, FOREGROUND_CANDLE_PRIORITY,
), ),
]); ]);
} }
@@ -479,7 +485,7 @@ export class TbankSyncService {
await add( await add(
this.ledger.read(userId, accountId, { this.ledger.read(userId, accountId, {
from, from,
to: new Date(Date.parse(end) - 1).toISOString(), to: new Date(Date.parse(end) - EXCLUSIVE_END_OFFSET_MS).toISOString(),
...(instrumentUid ? { instrumentUid } : {}), ...(instrumentUid ? { instrumentUid } : {}),
sync: 'observe', sync: 'observe',
}), }),
@@ -509,7 +515,7 @@ export class TbankSyncService {
state, state,
errorCode: observed.errorCode, errorCode: observed.errorCode,
progress: { progress: {
completed: state === 'running' ? 0 : 1, completed: state === 'running' ? PENDING_CHILD_COUNT : COMPLETED_CHILD_COUNT,
total: 1, total: 1,
discovering: false, discovering: false,
}, },
@@ -10,7 +10,7 @@ import {
multiply, multiply,
} from '../../../common/decimal'; } from '../../../common/decimal';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { MILLISECONDS_PER_SECOND } from '../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time';
import { decimal } from '../../../integrations/tbank/decimal'; import { decimal } from '../../../integrations/tbank/decimal';
import { import {
InstrumentMetadataService, InstrumentMetadataService,
@@ -30,6 +30,9 @@ import {
UpcomingEventsDto, UpcomingEventsDto,
UpcomingEventsQueryDto, UpcomingEventsQueryDto,
} from './upcoming-events.dto'; } from './upcoming-events.dto';
const FIRST_BATCH_OFFSET = 0;
const DEFAULT_EVENT_WINDOW_DAYS = 30; const DEFAULT_EVENT_WINDOW_DAYS = 30;
const calendarReadBatchSize = 100; const calendarReadBatchSize = 100;
type Position = PortfolioPositionDto; type Position = PortfolioPositionDto;
@@ -81,7 +84,7 @@ export class UpcomingEventsService {
); );
const uids = [...new Set(identities.map((entry) => entry.uid))]; const uids = [...new Set(identities.map((entry) => entry.uid))];
for ( for (
let offset = 0; let offset = FIRST_BATCH_OFFSET;
offset < uids.length; offset < uids.length;
offset += calendarReadBatchSize offset += calendarReadBatchSize
) { ) {
@@ -219,7 +222,7 @@ export function dividendEvent(
type: 'dividend', type: 'dividend',
date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND) date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND)
.toISOString() .toISOString()
.slice(0, 10), .slice(ISO_DATE_START, ISO_DATE_LENGTH),
dateKind: 'registry', dateKind: 'registry',
projectedAmount: event.dividendNet ? projectedAmount: event.dividendNet ?
projectedMoney( projectedMoney(
@@ -259,10 +262,10 @@ function supported(position: Position): boolean {
} }
function dateWindow(query: UpcomingEventsQueryDto) { function dateWindow(query: UpcomingEventsQueryDto) {
const from = query.from ?? new Date().toISOString().slice(0, 10); const from = query.from ?? new Date().toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
const end = new Date(`${from}T00:00:00.000Z`); const end = new Date(`${from}T00:00:00.000Z`);
end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS); end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS);
const to = query.to ?? end.toISOString().slice(0, 10); const to = query.to ?? end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
if (from > to) { if (from > to) {
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' }); throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
} }
+16
View File
@@ -45,3 +45,19 @@ test('focused tests are rejected and generated code is excluded', async () => {
assert.ok(await eslint.isPathIgnored('generated/prisma/client.ts')); assert.ok(await eslint.isPathIgnored('generated/prisma/client.ts'));
assert.ok(await eslint.isPathIgnored('dist/src/main.js')); assert.ok(await eslint.isPathIgnored('dist/src/main.js'));
}); });
test('numeric literals require names, including previously ignored numbers and bigints', async () => {
const literals = [-60, -24, -10, -2, -1, 0, 1, 2, 10, 24, 60].map(String);
for (const literal of [...literals, ...literals.map((value) => `${value}n`)]) {
const [inline] = await eslint.lintText(
`export function value() { return ${literal}; }`,
{ filePath: 'src/main.ts' },
);
assert.ok(inline.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal);
const [named] = await eslint.lintText(
`const boundary = ${literal}; export function value() { return boundary; }`,
{ filePath: 'src/main.ts' },
);
assert.ok(!named.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal);
}
});