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