refactor: simplify backend and guard boundaries #42
+5
-4
@@ -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
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
+24
-15
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user