refactor: centralize domain values and enforce numeric lint

This commit is contained in:
2026-09-13 14:37:39 +03:00
parent 8ba005346d
commit 8637ad2f34
44 changed files with 543 additions and 174 deletions
+1
View File
@@ -7,6 +7,7 @@
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
| Новый файл/suffix/layer; поиск feature owner; progressive structure; перенос responsibility | [Architecture](conventions/backend-architecture.md) | | Новый файл/suffix/layer; поиск feature owner; progressive structure; перенос responsibility | [Architecture](conventions/backend-architecture.md) |
| TS vocabulary, domain literals, magic values | [Domain literals](conventions/backend-architecture.md#domain-literals-and-magic-values) |
| Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) | | Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) |
| Repository/data access; Prisma/schema/query, transaction, migration, atomic write | [Data](conventions/backend-data.md) | | Repository/data access; Prisma/schema/query, transaction, migration, atomic write | [Data](conventions/backend-data.md) |
| Тесты или behavior с testing risk | [Testing](conventions/backend-testing.md) | | Тесты или behavior с testing risk | [Testing](conventions/backend-testing.md) |
+50
View File
@@ -203,6 +203,56 @@ protobuf units/nano conversion — T-Bank integration. Domain используе
`money.types.ts`, а не Swagger DTO; это общая форма денег для карт и портфеля. `money.types.ts`, а не Swagger DTO; это общая форма денег для карт и портфеля.
Давай функциям предметные имена; не создавай глобальные `utils.ts`/`helpers.ts`. Давай функциям предметные имена; не создавай глобальные `utils.ts`/`helpers.ts`.
## Domain literals and magic values
Finite business vocabulary must be modeled explicitly; incidental implementation
literals stay inline. Это относится к error codes, statuses, operation/account/
instrument/transaction types, permissions, roles, feature states и бизнес-режимам.
Перед новым определением найди existing owner/type/value. В branching и comparisons
используй символическое значение владельца; не повторяй raw domain literals.
Храни словарь у domain/module/entity с соблюдением границ слоёв, без глобальной
свалки `constants.ts`. Сохраняй принятый pattern; для нового project-owned словаря
без подходящего аналога используй `as const` с derived union:
```ts
const BankErrorCode = {
InvalidDate: "BANK_INVALID_DATE",
ImmutableOperation: "BANK_IMMUTABLE_OPERATION",
} as const;
type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
// Вместо code === "BANK_INVALID_DATE":
if (code === BankErrorCode.InvalidDate) { /* existing handling */ }
```
Значимые numeric policies (timeout, retry, page size, threshold, coefficient)
называй по смыслу и единицам: `REQUEST_TIMEOUT_MS`, `CACHE_TTL_SECONDS`,
`MAX_RETRY_COUNT`, `PAGE_SIZE`. Оставляй inline UI labels в принятом месте,
log messages, индексы, `index + 1`, `Math.max(value, 0)`, `page ?? 1`,
`percentage * 100` и очевидные локальные детали без semantic benefit.
При неясном owner/смысле оставь значение и укажи ограничение в review.
Не мигрируй корректные existing enum/union styles массово. Не добавляй ради
правила factories, registries, schemas, loaders, abstraction layers, dependencies,
custom lint/AST rules, generation/validation scripts или тесты существования
констант. Semantic review проверяет ownership, единое написание и неизменность
wire values; обычные behavior tests проверяют поведение.
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
что application/domain. Например, `BankOperationKind` принадлежит bank-cards/domain,
а DTO получает допустимые значения через `Object.values(BankOperationKind)`.
Frontend переиспользует generated OpenAPI types/values по возможностям генератора,
а не определяет независимый backend-owned словарь. Внешние/generated enums
сохраняй; 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 получает предметное имя и единицы;
semantic magic strings проверяются conventions/review без нового инструмента.
## Logging ## Logging
Единственный logger — nestjs-pino; application events используют существующий Единственный logger — nestjs-pino; application events используют существующий
+42 -1
View File
@@ -26,6 +26,43 @@ export default tseslint.config(
}, },
{ {
rules: { rules: {
'no-magic-numbers': 'off',
'@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,
ignoreNumericLiteralTypes: true,
ignoreReadonlyClassProperties: true,
ignoreTypeIndexes: true,
enforceConst: true,
},
],
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'error', '@typescript-eslint/no-non-null-assertion': 'error',
'no-restricted-syntax': [ 'no-restricted-syntax': [
@@ -39,7 +76,11 @@ export default tseslint.config(
], ],
'@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }], 'prettier/prettier': ['error', { endOfLine: 'auto' }],
}, },
}, },
{
files: ['**/*.spec.ts', 'test/**/*.ts'],
rules: { '@typescript-eslint/no-magic-numbers': 'off' },
},
); );
@@ -1,4 +1,4 @@
import { applyDecorators } from '@nestjs/common'; import { HttpStatus, applyDecorators } from '@nestjs/common';
import { import {
ApiExtraModels, ApiExtraModels,
ApiProperty, ApiProperty,
@@ -35,10 +35,13 @@ function problemResponse(status: number, description: string) {
export function ApiProblemResponses(): MethodDecorator & ClassDecorator { export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
return applyDecorators( return applyDecorators(
ApiExtraModels(ProblemDetailsDto), ApiExtraModels(ProblemDetailsDto),
problemResponse(400, 'Invalid request'), problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
problemResponse(401, 'Authentication required'), problemResponse(HttpStatus.UNAUTHORIZED, 'Authentication required'),
problemResponse(404, 'Resource not found'), problemResponse(HttpStatus.NOT_FOUND, 'Resource not found'),
problemResponse(503, 'Upstream service unavailable'), problemResponse(
problemResponse(500, 'Internal server error'), HttpStatus.SERVICE_UNAVAILABLE,
'Upstream service unavailable',
),
problemResponse(HttpStatus.INTERNAL_SERVER_ERROR, 'Internal server error'),
); );
} }
@@ -1,3 +1,4 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, NestMiddleware } from '@nestjs/common'; import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -46,11 +47,11 @@ export class RequestLoggingMiddleware implements NestMiddleware {
: {}), : {}),
}; };
const level = const level =
response.statusCode >= 500 response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
? 'error' ? 'error'
: response.statusCode === 429 || : response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === 401 || response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === 403 response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
? 'warn' ? 'warn'
: 'info'; : 'info';
this.logger.event(level, 'http.request.completed', context); this.logger.event(level, 'http.request.completed', context);
+2
View File
@@ -0,0 +1,2 @@
export const MILLISECONDS_PER_SECOND = 1_000;
export const MILLISECONDS_PER_DAY = 86_400_000;
+19 -5
View File
@@ -1,3 +1,8 @@
const DEFAULT_PORT = 3000;
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
const MAX_PORT = 65_535;
const MIN_JWT_SECRET_LENGTH = 32;
const TOKEN_KEY_BYTES = 32;
export interface Environment { export interface Environment {
DATABASE_URL: string; DATABASE_URL: string;
FRONTEND_ORIGIN: string; FRONTEND_ORIGIN: string;
@@ -13,12 +18,14 @@ export interface Environment {
export function validateEnv(config: Record<string, unknown>): Environment { export function validateEnv(config: Record<string, unknown>): Environment {
const databaseUrl = config['DATABASE_URL']; const databaseUrl = config['DATABASE_URL'];
const frontendOrigin = config['FRONTEND_ORIGIN']; const frontendOrigin = config['FRONTEND_ORIGIN'];
const port = config['PORT'] ?? 3000; const port = config['PORT'] ?? DEFAULT_PORT;
const jwtSecret = config['JWT_SECRET']; const jwtSecret = config['JWT_SECRET'];
const tokenEncryptionKey = config['TOKEN_ENCRYPTION_KEY']; const tokenEncryptionKey = config['TOKEN_ENCRYPTION_KEY'];
const tbankEndpoint = const tbankEndpoint =
config['TBANK_GRPC_ENDPOINT'] ?? 'invest-public-api.tbank.ru:443'; config['TBANK_GRPC_ENDPOINT'] ?? 'invest-public-api.tbank.ru:443';
const tbankRateLimit = config['TBANK_RATE_LIMIT_PER_SECOND'] ?? 5; const tbankRateLimit =
config['TBANK_RATE_LIMIT_PER_SECOND'] ??
DEFAULT_TBANK_RATE_LIMIT_PER_SECOND;
const tbankCaCertPath = config['TBANK_CA_CERT_PATH']; const tbankCaCertPath = config['TBANK_CA_CERT_PATH'];
const logLevel = config['LOG_LEVEL'] ?? 'info'; const logLevel = config['LOG_LEVEL'] ?? 'info';
@@ -47,18 +54,25 @@ export function validateEnv(config: Record<string, unknown>): Environment {
} }
const parsedPort = Number(port); const parsedPort = Number(port);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { if (
!Number.isInteger(parsedPort) ||
parsedPort < 1 ||
parsedPort > MAX_PORT
) {
throw new Error('PORT must be a valid port number'); throw new Error('PORT must be a valid port number');
} }
if (typeof jwtSecret !== 'string' || jwtSecret.length < 32) { if (
typeof jwtSecret !== 'string' ||
jwtSecret.length < MIN_JWT_SECRET_LENGTH
) {
throw new Error('JWT_SECRET must be at least 32 characters long'); throw new Error('JWT_SECRET must be at least 32 characters long');
} }
if ( if (
typeof tokenEncryptionKey !== 'string' || typeof tokenEncryptionKey !== 'string' ||
!/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) || !/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) ||
Buffer.from(tokenEncryptionKey, 'base64').length !== 32 Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
) { ) {
throw new Error( throw new Error(
'TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key', 'TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key',
+6 -3
View File
@@ -1,5 +1,8 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
const FALLBACK_PAGE_SIZE = 100;
const baseUrl = 'https://iss.moex.com/iss'; const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000; const timeoutMs = 10_000;
@@ -121,8 +124,8 @@ export class MoexClient {
if (!paginated) return rows; if (!paginated) return rows;
const cursorBlock = response[`${block}.cursor`]; const cursorBlock = response[`${block}.cursor`];
if (!cursorBlock) { if (!cursorBlock) {
if (current.length < 100) return rows; if (current.length < FALLBACK_PAGE_SIZE) return rows;
start += 100; start += FALLBACK_PAGE_SIZE;
continue; continue;
} }
const cursor = parseRows(cursorBlock)[0]; const cursor = parseRows(cursorBlock)[0];
@@ -145,7 +148,7 @@ export class MoexClient {
const response = await fetch(url, { const response = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs), signal: AbortSignal.timeout(timeoutMs),
}); });
if (response.status === 404) return null; if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
const body: unknown = await response.json(); const body: unknown = await response.json();
if (!body || typeof body !== 'object') if (!body || typeof body !== 'object')
+15 -9
View File
@@ -2,6 +2,12 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
const REGISTRY_CACHE_TTL_MS = 21_600_000;
const REQUEST_TIMEOUT_MS = 10_000;
const COLUMN_COUNT = 6;
const ISIN_COLUMN = 3;
const DATE_COLUMN = 5;
const registryUrl = 'https://ratings.ru/ratings/issues/'; const registryUrl = 'https://ratings.ru/ratings/issues/';
export type NcrRating = { export type NcrRating = {
agency: 'NCR'; agency: 'NCR';
@@ -18,7 +24,7 @@ export class NcrRatingsClient {
private readonly cache = new LRUCache< private readonly cache = new LRUCache<
string, string,
Promise<Map<string, NcrRating>> Promise<Map<string, NcrRating>>
>({ max: 1, ttl: 6 * 60 * 60_000 }); >({ max: 1, ttl: REGISTRY_CACHE_TTL_MS });
async rating(isin: string): Promise<NcrRating | null> { async rating(isin: string): Promise<NcrRating | null> {
const key = isin.trim().toUpperCase(); const key = isin.trim().toUpperCase();
@@ -38,7 +44,7 @@ export class NcrRatingsClient {
private async registry(): Promise<Map<string, NcrRating>> { private async registry(): Promise<Map<string, NcrRating>> {
const response = await fetch(registryUrl, { const response = await fetch(registryUrl, {
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}); });
if (!response.ok) throw new Error('NCR unavailable'); if (!response.ok) throw new Error('NCR unavailable');
const $ = load(await response.text()); const $ = load(await response.text());
@@ -49,18 +55,18 @@ export class NcrRatingsClient {
.get(); .get();
if ( if (
table.length !== 1 || table.length !== 1 ||
headings.length !== 6 || headings.length !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' || headings[2] !== 'Рейтинг' ||
headings[3] !== 'ISIN' || headings[ISIN_COLUMN] !== 'ISIN' ||
headings[5] !== 'Дата' headings[DATE_COLUMN] !== 'Дата'
) )
throw new Error('NCR registry changed'); throw new Error('NCR registry changed');
const observedAt = new Date().toISOString(); const observedAt = new Date().toISOString();
const result = new Map<string, NcrRating>(); const result = new Map<string, NcrRating>();
table.find('tbody tr').each((_, row) => { table.find('tbody tr').each((_, row) => {
const cells = $(row).find('td'); const cells = $(row).find('td');
if (cells.length !== 6) throw new Error('NCR row changed'); if (cells.length !== COLUMN_COUNT) throw new Error('NCR row changed');
const isin = cells.eq(3).text().trim().toUpperCase(); const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
// Unassigned issues cannot safely be matched to a held instrument. // Unassigned issues cannot safely be matched to a held instrument.
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return; if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim(); const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
@@ -73,9 +79,9 @@ export class NcrRatingsClient {
) )
throw new Error('NCR rating changed'); throw new Error('NCR rating changed');
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec( const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
cells.eq(5).text().trim(), cells.eq(DATE_COLUMN).text().trim(),
); );
const href = cells.eq(3).find('a').attr('href'); const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href))
throw new Error('NCR identity missing'); throw new Error('NCR identity missing');
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { TbankAccount } from './tbank.client'; import { TbankAccount } from './tbank.client';
const accountTypes = { const accountTypes = {
@@ -51,7 +52,7 @@ export function accessibleTbankAccount(
function openedOn(seconds: string | number | undefined): string | null { function openedOn(seconds: string | number | undefined): string | null {
if (seconds === undefined) return null; if (seconds === undefined) return null;
const date = new Date(Number(seconds) * 1000); const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
if (Number.isNaN(date.valueOf())) return null; if (Number.isNaN(date.valueOf())) return null;
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
+25 -8
View File
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { import {
ChannelCredentials, ChannelCredentials,
Client, Client,
@@ -28,6 +29,13 @@ import type {
TbankShare, TbankShare,
} from './tbank-market.types'; } from './tbank-market.types';
const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2;
const MAX_QUEUE_SIZE = 50;
const METADATA_RETRY_DELAY_MS = 2_000;
const RPC_RETRY_DELAY_MS = 1_000;
const requestDeadlineMs = 15_000; const requestDeadlineMs = 15_000;
type Unary<Request, Response> = ( type Unary<Request, Response> = (
@@ -134,7 +142,7 @@ class RateLimiter {
private drain(): void { private drain(): void {
const now = Date.now(); const now = Date.now();
if (now - this.windowStartedAt >= 1000) { if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
this.windowStartedAt = now; this.windowStartedAt = now;
this.starts = 0; this.starts = 0;
} }
@@ -145,7 +153,7 @@ class RateLimiter {
if (this.waiting.length && this.starts === this.perSecond) { if (this.waiting.length && this.starts === this.perSecond) {
setTimeout( setTimeout(
() => this.drain(), () => this.drain(),
Math.max(1, 1000 - (now - this.windowStartedAt)), Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)),
); );
} }
} }
@@ -422,8 +430,12 @@ export class TbankClient {
'getCandles', 'getCandles',
{ {
instrumentId: uid, instrumentId: uid,
from: { seconds: String(Math.floor(from.valueOf() / 1000)) }, from: {
to: { seconds: String(Math.floor(to.valueOf() / 1000)) }, seconds: String(Math.floor(from.valueOf() / MILLISECONDS_PER_SECOND)),
},
to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
},
interval: 'CANDLE_INTERVAL_DAY', interval: 'CANDLE_INTERVAL_DAY',
limit: 2400, limit: 2400,
}, },
@@ -456,10 +468,10 @@ export class TbankClient {
request: object, request: object,
): Promise<Response> { ): Promise<Response> {
const metadataCall = method === 'getInstrumentBy'; const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? 3 : 2; const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) { for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
const startedAt = performance.now(); const startedAt = performance.now();
const queueFull = this.queue.size >= 50; const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try { try {
if (queueFull) if (queueFull)
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
@@ -492,7 +504,12 @@ export class TbankClient {
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
await new Promise((resolve) => await new Promise((resolve) =>
setTimeout(resolve, metadataCall ? attempts * 2000 : 1000), setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
),
); );
continue; continue;
} }
@@ -537,5 +554,5 @@ export class TbankClient {
} }
function utcSeconds(date: string): number { function utcSeconds(date: string): number {
return Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000); return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND);
} }
+4 -1
View File
@@ -1,5 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const KEY_SIZE_BYTES = 32;
const VERSION = 1; const VERSION = 1;
const NONCE_SIZE = 12; const NONCE_SIZE = 12;
const TAG_SIZE = 16; const TAG_SIZE = 16;
@@ -9,7 +11,8 @@ export class TbankCrypto {
static fromBase64(key: string): TbankCrypto { static fromBase64(key: string): TbankCrypto {
const decoded = Buffer.from(key, 'base64'); const decoded = Buffer.from(key, 'base64');
if (decoded.length !== 32) throw new Error('Invalid token encryption key'); if (decoded.length !== KEY_SIZE_BYTES)
throw new Error('Invalid token encryption key');
return new TbankCrypto(decoded); return new TbankCrypto(decoded);
} }
+5 -2
View File
@@ -10,6 +10,9 @@ import { Session, User } from '../../../../generated/prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types'; import { AuthUser, RefreshPayload } from './auth.types';
const PASSWORD_HASH_ROUNDS = 12;
const REFRESH_TTL_MS = 604_800_000;
const ACCESS_TTL = '15m'; const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d'; const REFRESH_TTL = '7d';
const DUMMY_PASSWORD_HASH = const DUMMY_PASSWORD_HASH =
@@ -39,7 +42,7 @@ export class AuthService {
data: { data: {
id: randomUUID(), id: randomUUID(),
email: normalizedEmail, email: normalizedEmail,
passwordHash: await bcrypt.hash(password, 12), passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
}, },
}); });
} catch (error) { } catch (error) {
@@ -162,7 +165,7 @@ export class AuthService {
); );
const data = { const data = {
refreshTokenHash: this.hash(refreshToken), refreshTokenHash: this.hash(refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
}; };
if (expectedRefreshTokenHash) { if (expectedRefreshTokenHash) {
const updated = await this.prisma.session.updateMany({ const updated = await this.prisma.session.updateMany({
@@ -3,6 +3,7 @@ import {
Controller, Controller,
Get, Get,
HttpCode, HttpCode,
HttpStatus,
Post, Post,
Req, Req,
Res, Res,
@@ -49,7 +50,7 @@ export class AuthController {
constructor(private readonly auth: AuthService) {} constructor(private readonly auth: AuthService) {}
@Post('register') @Post('register')
@HttpCode(200) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async register( async register(
@Body() credentials: CredentialsDto, @Body() credentials: CredentialsDto,
@@ -62,7 +63,7 @@ export class AuthController {
} }
@Post('login') @Post('login')
@HttpCode(200) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async login( async login(
@Body() credentials: CredentialsDto, @Body() credentials: CredentialsDto,
@@ -75,7 +76,7 @@ export class AuthController {
} }
@Post('refresh') @Post('refresh')
@HttpCode(200) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async refresh( async refresh(
@Req() request: Request, @Req() request: Request,
@@ -92,7 +93,7 @@ export class AuthController {
} }
@Post('logout') @Post('logout')
@HttpCode(204) @HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse() @ApiNoContentResponse()
async logout( async logout(
@Req() request: Request, @Req() request: Request,
+19 -6
View File
@@ -7,20 +7,26 @@ import {
ValidatorConstraint, ValidatorConstraint,
} from 'class-validator'; } from 'class-validator';
const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32;
const ASCII_DELETE = 127;
const MIN_PASSWORD_LENGTH = 12;
const MAX_PASSWORD_BYTES = 72;
@ValidatorConstraint({ name: 'email' }) @ValidatorConstraint({ name: 'email' })
class EmailConstraint { class EmailConstraint {
validate(value: unknown): boolean { validate(value: unknown): boolean {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
value.length <= 254 && value.length <= MAX_EMAIL_LENGTH &&
value.split('@').length === 2 && value.split('@').length === 2 &&
!value !value
.split('') .split('')
.some( .some(
(character) => (character) =>
/\s/.test(character) || /\s/.test(character) ||
character.charCodeAt(0) < 32 || character.charCodeAt(0) < ASCII_SPACE ||
character.charCodeAt(0) === 127, character.charCodeAt(0) === ASCII_DELETE,
) && ) &&
value.split('@').every(Boolean) value.split('@').every(Boolean)
); );
@@ -33,7 +39,10 @@ class EmailConstraint {
@ValidatorConstraint({ name: 'passwordBytes' }) @ValidatorConstraint({ name: 'passwordBytes' })
class PasswordBytesConstraint { class PasswordBytesConstraint {
validate(value: unknown): boolean { validate(value: unknown): boolean {
return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 72; return (
typeof value === 'string' &&
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
);
} }
defaultMessage(): string { defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes'; return 'must not exceed 72 UTF-8 bytes';
@@ -49,9 +58,13 @@ export class CredentialsDto {
@Validate(EmailConstraint) @Validate(EmailConstraint)
declare email: string; declare email: string;
@ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' }) @ApiProperty({
minLength: MIN_PASSWORD_LENGTH,
maxLength: MAX_PASSWORD_BYTES,
example: 'a secure password',
})
@IsString() @IsString()
@MinLength(12) @MinLength(MIN_PASSWORD_LENGTH)
@Validate(PasswordBytesConstraint) @Validate(PasswordBytesConstraint)
declare password: string; declare password: string;
} }
@@ -1,3 +1,7 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
@@ -13,7 +17,26 @@ import {
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations'; import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money'; import { parseRubMoney } from '../domain/rub-money';
export function fail(code = 'BANK_INVALID_INPUT'): never { export const BankErrorCode = {
InvalidInput: 'BANK_INVALID_INPUT',
VersionConflict: 'BANK_VERSION_CONFLICT',
NotFound: 'BANK_NOT_FOUND',
InvalidMoney: 'BANK_INVALID_MONEY',
InvalidDate: 'BANK_INVALID_DATE',
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION',
InvalidRefund: 'BANK_INVALID_REFUND',
RefundLimit: 'BANK_REFUND_LIMIT',
DependentRefunds: 'BANK_DEPENDENT_REFUNDS',
IntentConflict: 'BANK_INTENT_CONFLICT',
OperationDeleted: 'BANK_OPERATION_DELETED',
CardArchived: 'BANK_CARD_ARCHIVED',
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT',
WriteConflict: 'BANK_WRITE_CONFLICT',
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY',
} as const;
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
throw new BadRequestException({ code }); throw new BadRequestException({ code });
} }
export function periodBounds(query: BankPeriodQueryDto) { export function periodBounds(query: BankPeriodQueryDto) {
@@ -35,11 +58,13 @@ export function periodBounds(query: BankPeriodQueryDto) {
return fail(); return fail();
return { from: query.from, to: query.to }; return { from: query.from, to: query.to };
} }
export function conflict(code = 'BANK_VERSION_CONFLICT'): never { export function conflict(
code: BankErrorCode = BankErrorCode.VersionConflict,
): never {
throw new ConflictException({ code }); throw new ConflictException({ code });
} }
export function missing(): never { export function missing(): never {
throw new NotFoundException({ code: 'BANK_NOT_FOUND' }); throw new NotFoundException({ code: BankErrorCode.NotFound });
} }
export const fingerprint = (value: unknown) => export const fingerprint = (value: unknown) =>
createHash('sha256') createHash('sha256')
@@ -56,16 +81,16 @@ export function amount(
try { try {
return parseRubMoney(value, positive); return parseRubMoney(value, positive);
} catch { } catch {
return fail('BANK_INVALID_MONEY'); return fail(BankErrorCode.InvalidMoney);
} }
} }
export function calendar(value: string) { export function calendar(value: string) {
if (!isCalendarDate(value)) fail('BANK_INVALID_DATE'); if (!isCalendarDate(value)) fail(BankErrorCode.InvalidDate);
return value; return value;
} }
export function currentDate(value: string) { export function currentDate(value: string) {
calendar(value); calendar(value);
if (value > moscowToday()) fail('BANK_INVALID_DATE'); if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
} }
export function name(value: string, max: number) { export function name(value: string, max: number) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > max) if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
@@ -74,7 +99,7 @@ export function name(value: string, max: number) {
} }
export function cardInput(dto: CreateBankCardDto) { export function cardInput(dto: CreateBankCardDto) {
const bankName = dto.bankName?.trim() || null; const bankName = dto.bankName?.trim() || null;
if (bankName && bankName.length > 100) fail(); if (bankName && bankName.length > MAX_CARD_NAME_LENGTH) fail();
if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail(); if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail();
if ( if (
dto.paymentSystem != null && dto.paymentSystem != null &&
@@ -83,7 +108,7 @@ export function cardInput(dto: CreateBankCardDto) {
fail(); fail();
return { return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }), ...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, 100), name: name(dto.name, MAX_CARD_NAME_LENGTH),
bankName, bankName,
lastFour: dto.lastFour || null, lastFour: dto.lastFour || null,
openingBalanceKopecks: amount(dto.openingBalance), openingBalanceKopecks: amount(dto.openingBalance),
@@ -93,7 +118,8 @@ export function cardInput(dto: CreateBankCardDto) {
export function operationInput(dto: CreateBankOperationDto) { export function operationInput(dto: CreateBankOperationDto) {
if ( if (
dto.description != null && dto.description != null &&
(typeof dto.description !== 'string' || dto.description.length > 500) (typeof dto.description !== 'string' ||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
) )
fail(); fail();
return { return {
@@ -11,6 +11,7 @@ import { rubMoney } from '../domain/rub-money';
import { import {
cardInput, cardInput,
BankErrorCode,
conflict, conflict,
currentDate, currentDate,
fail, fail,
@@ -71,7 +72,7 @@ export class BankCardsService {
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash) if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT'); conflict(BankErrorCode.IntentConflict);
return this.cardView(tx, existing); return this.cardView(tx, existing);
} }
currentDate(input.openingDate); currentDate(input.openingDate);
@@ -110,7 +111,8 @@ export class BankCardsService {
const financial = const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks || input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate; input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt) conflict('BANK_CARD_ARCHIVED'); if (financial && previous.archivedAt)
conflict(BankErrorCode.CardArchived);
currentDate(input.openingDate); currentDate(input.openingDate);
if ( if (
await tx.bankCardOperation.findFirst({ await tx.bankCardOperation.findFirst({
@@ -122,7 +124,7 @@ export class BankCardsService {
}, },
}) })
) )
fail('BANK_OPENING_DATE_CONFLICT'); fail(BankErrorCode.OpeningDateConflict);
const changed = await tx.bankCard.updateMany({ const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version }, where: { id, userId, version: dto.version },
data: { data: {
@@ -1,3 +1,4 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { GoneException, Injectable } from '@nestjs/common'; import { GoneException, Injectable } from '@nestjs/common';
import { import {
BankCardOperation, BankCardOperation,
@@ -6,6 +7,7 @@ import {
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { import {
BankOperationDto, BankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
BankOperationsQueryDto, BankOperationsQueryDto,
BankPageQueryDto, BankPageQueryDto,
CreateBankOperationDto, CreateBankOperationDto,
@@ -14,6 +16,7 @@ import {
import { rubMoney } from '../domain/rub-money'; import { rubMoney } from '../domain/rub-money';
import { import {
BankErrorCode,
conflict, conflict,
currentDate, currentDate,
fail, fail,
@@ -48,7 +51,7 @@ export class BankOperationsService {
}) })
: null; : null;
const refunds = const refunds =
row.kind === 'expense' || source row.kind === BankOperationKind.Expense || source
? await tx.bankCardOperation.findMany({ ? await tx.bankCardOperation.findMany({
where: { where: {
userId: row.userId, userId: row.userId,
@@ -70,7 +73,7 @@ export class BankOperationsService {
originalExpenseId: row.originalExpenseId, originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks), amount: rubMoney(row.amountKopecks),
availableRefund: availableRefund:
row.kind === 'expense' || source row.kind === BankOperationKind.Expense || source
? rubMoney( ? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) - (source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
@@ -100,15 +103,15 @@ export class BankOperationsService {
(id === input.cardId || id === input.targetCardId) && (id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate input.date < card.openingDate
) )
fail('BANK_INVALID_DATE'); fail(BankErrorCode.InvalidDate);
} }
if ( if (
old && old &&
(old.kind !== input.kind || (old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId) old.originalExpenseId !== input.originalExpenseId)
) )
fail('BANK_IMMUTABLE_OPERATION'); fail(BankErrorCode.ImmutableOperation);
if (input.kind === 'transfer') { if (input.kind === BankOperationKind.Transfer) {
if ( if (
!input.targetCardId || !input.targetCardId ||
input.targetCardId === input.cardId || input.targetCardId === input.cardId ||
@@ -117,7 +120,7 @@ export class BankOperationsService {
) )
fail(); fail();
} else if (input.targetCardId) fail(); } else if (input.targetCardId) fail();
if (input.kind === 'expense') { if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail(); if (input.originalExpenseId) fail();
if (!input.categoryId) { if (!input.categoryId) {
const category = await tx.expenseCategory.findUnique({ const category = await tx.expenseCategory.findUnique({
@@ -133,8 +136,9 @@ export class BankOperationsService {
) )
missing(); missing();
} else if (input.categoryId) fail(); } else if (input.categoryId) fail();
if (input.kind !== 'refund' && input.originalExpenseId) fail(); if (input.kind !== BankOperationKind.Refund && input.originalExpenseId)
if (input.kind === 'refund') { fail();
if (input.kind === BankOperationKind.Refund) {
if (!input.originalExpenseId) fail(); if (!input.originalExpenseId) fail();
const source = await this.repository.operation( const source = await this.repository.operation(
tx, tx,
@@ -143,10 +147,10 @@ export class BankOperationsService {
); );
if ( if (
source.deletedAt || source.deletedAt ||
source.kind !== 'expense' || source.kind !== BankOperationKind.Expense ||
source.cardId !== input.cardId source.cardId !== input.cardId
) )
fail('BANK_INVALID_REFUND'); fail(BankErrorCode.InvalidRefund);
if ( if (
( (
await tx.bankCardOperation.updateMany({ await tx.bankCardOperation.updateMany({
@@ -169,15 +173,15 @@ export class BankOperationsService {
...(old ? { id: { not: old.id } } : {}), ...(old ? { id: { not: old.id } } : {}),
}, },
}); });
if (input.date < source.date) fail('BANK_INVALID_DATE'); if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if ( if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
input.amountKopecks > input.amountKopecks >
source.amountKopecks source.amountKopecks
) )
conflict('BANK_REFUND_LIMIT'); conflict(BankErrorCode.RefundLimit);
} }
if (old?.kind === 'expense') { if (old?.kind === BankOperationKind.Expense) {
const refunds = await tx.bankCardOperation.findMany({ const refunds = await tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: old.id, deletedAt: null }, where: { userId, originalExpenseId: old.id, deletedAt: null },
}); });
@@ -188,7 +192,7 @@ export class BankOperationsService {
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
input.amountKopecks) input.amountKopecks)
) )
conflict('BANK_DEPENDENT_REFUNDS'); conflict(BankErrorCode.DependentRefunds);
} }
} }
createOperation(userId: string, dto: CreateBankOperationDto) { createOperation(userId: string, dto: CreateBankOperationDto) {
@@ -201,9 +205,9 @@ export class BankOperationsService {
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash) if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT'); conflict(BankErrorCode.IntentConflict);
if (existing.deletedAt) if (existing.deletedAt)
throw new GoneException({ code: 'BANK_OPERATION_DELETED' }); throw new GoneException({ code: BankErrorCode.OperationDeleted });
return this.operationView(tx, existing); return this.operationView(tx, existing);
} }
const input = { ...normalized }; const input = { ...normalized };
@@ -265,12 +269,12 @@ export class BankOperationsService {
if (row.targetCardId) if (row.targetCardId)
await this.repository.card(tx, userId, row.targetCardId, true); await this.repository.card(tx, userId, row.targetCardId, true);
if ( if (
row.kind === 'expense' && row.kind === BankOperationKind.Expense &&
(await tx.bankCardOperation.findFirst({ (await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null }, where: { userId, originalExpenseId: id, deletedAt: null },
})) }))
) )
conflict('BANK_DEPENDENT_REFUNDS'); conflict(BankErrorCode.DependentRefunds);
if (row.originalExpenseId) { if (row.originalExpenseId) {
const source = await this.repository.operation( const source = await this.repository.operation(
tx, tx,
@@ -310,14 +314,14 @@ export class BankOperationsService {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const page = query.page ?? 1, const page = query.page ?? 1,
limit = query.limit ?? 50; limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE;
const where: Prisma.BankCardOperationWhereInput = { const where: Prisma.BankCardOperationWhereInput = {
userId, userId,
deletedAt: null, deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }], OR: [{ cardId: id }, { targetCardId: id }],
...(period ...(period
? { date: { gte: period.from, lte: period.to } } ? { date: { gte: period.from, lte: period.to } }
: { kind: 'expense' }), : { kind: BankOperationKind.Expense }),
}; };
const total = await tx.bankCardOperation.count({ where }); const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
@@ -1,3 +1,4 @@
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
@@ -24,7 +25,7 @@ export class ExpenseCategoriesService {
})); }));
} }
createCategory(userId: string, value: string) { createCategory(userId: string, value: string) {
const label = name(value, 80); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
await this.repository.seed(tx, userId); await this.repository.seed(tx, userId);
const row = await tx.expenseCategory.create({ const row = await tx.expenseCategory.create({
@@ -44,7 +45,7 @@ export class ExpenseCategoriesService {
}); });
} }
updateCategory(userId: string, id: string, value: string, version: number) { updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, 80); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } }); const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing(); if (!row) missing();
@@ -0,0 +1,3 @@
export const MAX_CARD_NAME_LENGTH = 100;
export const MAX_CATEGORY_NAME_LENGTH = 80;
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
@@ -1,3 +1,6 @@
import { BankOperationKind } from './bank-operation-kind';
const ISO_MONTH_LENGTH = 7;
export function moscowToday(now = new Date()): string { export function moscowToday(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', { return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Moscow', timeZone: 'Europe/Moscow',
@@ -30,12 +33,21 @@ export function bankBalance(
let balance = opening; let balance = opening;
for (const operation of operations) { for (const operation of operations) {
if (operation.cardId === cardId) { if (operation.cardId === cardId) {
if (operation.kind === 'expense' || operation.kind === 'transfer') if (
operation.kind === BankOperationKind.Expense ||
operation.kind === BankOperationKind.Transfer
)
balance -= operation.amountKopecks; balance -= operation.amountKopecks;
else if (operation.kind === 'topup' || operation.kind === 'refund') else if (
operation.kind === BankOperationKind.Topup ||
operation.kind === BankOperationKind.Refund
)
balance += operation.amountKopecks; balance += operation.amountKopecks;
} }
if (operation.kind === 'transfer' && operation.targetCardId === cardId) if (
operation.kind === BankOperationKind.Transfer &&
operation.targetCardId === cardId
)
balance += operation.amountKopecks; balance += operation.amountKopecks;
} }
return balance; return balance;
@@ -60,13 +72,23 @@ export function bankSummary(
for (const operation of operations) { for (const operation of operations) {
if ( if (
typeof period === 'string' typeof period === 'string'
? operation.date.slice(0, 7) !== period ? operation.date.slice(0, ISO_MONTH_LENGTH) !== period
: operation.date < period.from || operation.date > period.to : operation.date < period.from || operation.date > period.to
) )
continue; continue;
if (operation.kind !== 'expense' && operation.kind !== 'refund') continue; if (
const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n; operation.kind !== BankOperationKind.Expense &&
const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n; operation.kind !== BankOperationKind.Refund
)
continue;
const expense =
operation.kind === BankOperationKind.Expense
? operation.amountKopecks
: 0n;
const refund =
operation.kind === BankOperationKind.Refund
? operation.amountKopecks
: 0n;
expenses += expense; expenses += expense;
refunds += refund; refunds += refund;
if (operation.categoryId !== null) { if (operation.categoryId !== null) {
@@ -0,0 +1,8 @@
export const BankOperationKind = {
Expense: 'expense',
Topup: 'topup',
Transfer: 'transfer',
Refund: 'refund',
} as const;
export type BankOperationKind =
(typeof BankOperationKind)[keyof typeof BankOperationKind];
@@ -1,6 +1,9 @@
import { decimalFromString, formatDecimal } from '../../../../common/decimal'; import { decimalFromString, formatDecimal } from '../../../../common/decimal';
import type { Money } from '../../../../common/money.types'; import type { Money } from '../../../../common/money.types';
const MIN_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_KOPECKS = 9_223_372_036_854_775_807n;
export function parseRubMoney(money: Money, positive = false): bigint { export function parseRubMoney(money: Money, positive = false): bigint {
if ( if (
money?.currency !== 'RUB' || money?.currency !== 'RUB' ||
@@ -12,7 +15,7 @@ export function parseRubMoney(money: Money, positive = false): bigint {
if (amount.scale > 2) if (amount.scale > 2)
throw new RangeError('RUB money supports at most two decimal places'); throw new RangeError('RUB money supports at most two decimal places');
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale); const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
if (kopecks < -(2n ** 63n) || kopecks > 2n ** 63n - 1n) if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS)
throw new RangeError('RUB money is outside the signed 64-bit range'); throw new RangeError('RUB money is outside the signed 64-bit range');
if (positive && kopecks <= 0n) if (positive && kopecks <= 0n)
throw new RangeError('Operation amount must be positive'); throw new RangeError('Operation amount must be positive');
@@ -1,10 +1,18 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client'; import { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { conflict, missing } from '../application/bank-card-input'; import {
BankErrorCode,
conflict,
missing,
} from '../application/bank-card-input';
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000;
const TRANSACTION_TIMEOUT_MS = 15_000;
const MAX_WRITE_RETRY_COUNT = 2;
const seeds = [ const seeds = [
'Продукты', 'Продукты',
'Кафе', 'Кафе',
@@ -23,8 +31,8 @@ export class BankCardsRepository {
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
try { try {
return await this.prisma.$transaction(run, { return await this.prisma.$transaction(run, {
maxWait: 10000, maxWait: TRANSACTION_MAX_WAIT_MS,
timeout: 15000, timeout: TRANSACTION_TIMEOUT_MS,
}); });
} catch (error) { } catch (error) {
const code = const code =
@@ -36,9 +44,10 @@ export class BankCardsRepository {
code === 'P2028' || code === 'P2028' ||
(error instanceof Error && (error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message)); /SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < 2) continue; if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT)
if (busy) conflict('BANK_WRITE_CONFLICT'); continue;
if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY'); if (busy) conflict(BankErrorCode.WriteConflict);
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
throw error; throw error;
} }
} }
@@ -46,7 +55,7 @@ export class BankCardsRepository {
async card(tx: Tx, userId: string, id: string, active = false) { async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } }); const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing(); if (!card) return missing();
if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED'); if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
return card; return card;
} }
async operation(tx: Tx, userId: string, id: string) { async operation(tx: Tx, userId: string, id: string) {
@@ -56,7 +65,7 @@ export class BankCardsRepository {
} }
async seed(tx: Tx, userId: string) { async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) { for (const [index, label] of seeds.entries()) {
const seedKey = index === 8 ? 'other' : String(index); const seedKey = index === seeds.length - 1 ? 'other' : String(index);
if ( if (
!(await tx.expenseCategory.findUnique({ !(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } }, where: { userId_seedKey: { userId, seedKey } },
@@ -85,7 +94,7 @@ export class BankCardsRepository {
userId, userId,
deletedAt: null, deletedAt: null,
date: { gte: period.from, lte: period.to }, date: { gte: period.from, lte: period.to },
kind: { in: ['expense', 'refund'] }, kind: { in: [BankOperationKind.Expense, BankOperationKind.Refund] },
...(cardId ? { cardId } : {}), ...(cardId ? { cardId } : {}),
}, },
include: { originalExpense: { select: { categoryId: true } } }, include: { originalExpense: { select: { categoryId: true } } },
@@ -1,3 +1,9 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_CATEGORY_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
import { import {
ApiProperty, ApiProperty,
ApiPropertyOptional, ApiPropertyOptional,
@@ -24,16 +30,13 @@ import {
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
export const DEFAULT_BANK_PAGE_SIZE = 50;
const MAX_BANK_PAGE_SIZE = 100;
const trim = ({ value }: { value: unknown }) => const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value; typeof value === 'string' ? value.trim() : value;
export const bankPaymentSystems = ['mir', 'visa', 'mastercard'] as const; export const bankPaymentSystems = ['mir', 'visa', 'mastercard'] as const;
export const bankOperationKinds = [ const bankOperationKinds = Object.values(BankOperationKind);
'expense',
'topup',
'transfer',
'refund',
] as const;
export type BankOperationKind = (typeof bankOperationKinds)[number];
export class RubMoneyInputDto { export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string; @ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' }) @ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@@ -43,17 +46,21 @@ export class RubMoneyInputDto {
} }
export class CreateBankCardDto { export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ minLength: 1, maxLength: 100 }) @ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH })
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(100) @MaxLength(MAX_CARD_NAME_LENGTH)
declare name: string; declare name: string;
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 }) @ApiPropertyOptional({
type: String,
nullable: true,
maxLength: MAX_CARD_NAME_LENGTH,
})
@IsOptional() @IsOptional()
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MaxLength(100) @MaxLength(MAX_CARD_NAME_LENGTH)
bankName?: string | null; bankName?: string | null;
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' }) @ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
@IsOptional() @IsOptional()
@@ -84,11 +91,11 @@ export class UpdateBankCardDto extends PartialType(
archived?: boolean; archived?: boolean;
} }
export class CategoryNameDto { export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: 80 }) @ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH })
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(80) @MaxLength(MAX_CATEGORY_NAME_LENGTH)
declare name: string; declare name: string;
} }
export class UpdateExpenseCategoryDto extends CategoryNameDto { export class UpdateExpenseCategoryDto extends CategoryNameDto {
@@ -121,10 +128,10 @@ export class CreateBankOperationDto {
@IsString() @IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
declare date: string; declare date: string;
@ApiPropertyOptional({ maxLength: 500 }) @ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsString() @IsString()
@MaxLength(500) @MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
description?: string; description?: string;
} }
export class UpdateBankOperationDto extends PartialType( export class UpdateBankOperationDto extends PartialType(
@@ -146,12 +153,16 @@ export class BankPageQueryDto {
@IsInt() @IsInt()
@Min(1) @Min(1)
page?: number; page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 }) @ApiPropertyOptional({
minimum: 1,
maximum: MAX_BANK_PAGE_SIZE,
default: DEFAULT_BANK_PAGE_SIZE,
})
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
@Max(100) @Max(MAX_BANK_PAGE_SIZE)
limit?: number; limit?: number;
} }
export class BankPeriodQueryDto { export class BankPeriodQueryDto {
@@ -4,6 +4,7 @@ import {
Delete, Delete,
Get, Get,
HttpCode, HttpCode,
HttpStatus,
Param, Param,
Patch, Patch,
Post, Post,
@@ -143,7 +144,7 @@ export class BankCardsController {
return this.operations.updateOperation(req.user.sub, id, body); return this.operations.updateOperation(req.user.sub, id, body);
} }
@Delete('operations/:id') @Delete('operations/:id')
@HttpCode(204) @HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse() @ApiNoContentResponse()
deleteOperation( deleteOperation(
@Req() req: AuthRequest, @Req() req: AuthRequest,
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_DAY } from '../../../common/time';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { import {
DateOnlyRange, DateOnlyRange,
@@ -6,6 +7,12 @@ import {
utcDate, utcDate,
} from '../investment-calendar'; } from '../investment-calendar';
const DAILY_BUCKET_MAX_DAYS = 45;
const WEEKLY_BUCKET_MAX_DAYS = 180;
const DAYS_PER_WEEK = 7;
const ISO_MONTH_LENGTH = 7;
const ISO_YEAR_LENGTH = 4;
export type IncomeBucketGranularity = 'day' | 'week' | 'month'; export type IncomeBucketGranularity = 'day' | 'week' | 'month';
export function planIncomePeriod( export function planIncomePeriod(
@@ -36,9 +43,9 @@ export function planIncomePeriod(
previous, previous,
yearAgo: yearAgoRange, yearAgo: yearAgoRange,
granularity: granularity:
rangeDays(selected) <= 45 rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
? 'day' ? 'day'
: rangeDays(selected) <= 180 : rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
? 'week' ? 'week'
: 'month', : 'month',
}; };
@@ -53,11 +60,19 @@ export function rangeBuckets(
const buckets: DateOnlyRange[] = []; const buckets: DateOnlyRange[] = [];
let cursor = let cursor =
granularity === 'week' granularity === 'week'
? addDays(range.from, -((utcDate(range.from).getUTCDay() + 6) % 7)) ? addDays(
: `${range.from.slice(0, 7)}-01`; range.from,
-(
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) %
DAYS_PER_WEEK
),
)
: `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`;
while (cursor <= range.to) { while (cursor <= range.to) {
const end = const end =
granularity === 'week' ? addDays(cursor, 6) : endOfMonth(cursor); granularity === 'week'
? addDays(cursor, DAYS_PER_WEEK - 1)
: endOfMonth(cursor);
buckets.push({ buckets.push({
from: cursor < range.from ? range.from : cursor, from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end, to: end > range.to ? range.to : end,
@@ -71,7 +86,7 @@ export function rangeDays(range: DateOnlyRange): number {
return ( return (
Math.round( Math.round(
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) / (utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
86_400_000, MILLISECONDS_PER_DAY,
) + 1 ) + 1
); );
} }
@@ -100,7 +115,7 @@ function dates(range: DateOnlyRange): string[] {
} }
function firstOfMonth(value: string): string { function firstOfMonth(value: string): string {
return `${value.slice(0, 7)}-01`; return `${value.slice(0, ISO_MONTH_LENGTH)}-01`;
} }
function endOfMonth(value: string): string { function endOfMonth(value: string): string {
@@ -126,7 +141,7 @@ function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
function yearAgo(range: DateOnlyRange): DateOnlyRange | null { function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => { const shift = (value: string) => {
const [year, month, day] = dateParts(value); const [year, month, day] = dateParts(value);
const result = `${String(year - 1).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; 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 return utcDate(result).toISOString().slice(0, 10) === result
? result ? result
: null; : null;
@@ -1,16 +1,21 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { ServiceUnavailableException } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common';
import type { import type {
TbankCandle, TbankCandle,
TbankTimestamp, TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
const MOSCOW_OFFSET_MS = 10_800_000;
export function timestamp(value?: TbankTimestamp): string | null { export function timestamp(value?: TbankTimestamp): string | null {
const seconds = Number(value?.seconds); const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0 return Number.isFinite(seconds) && seconds > 0
? new Date(seconds * 1000).toISOString() ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
: null; : null;
} }
export function moscowDay(iso: string): string { export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10); return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
.toISOString()
.slice(0, 10);
} }
export function previousClose( export function previousClose(
candles: TbankCandle[], candles: TbankCandle[],
@@ -13,6 +13,9 @@ import {
} from '../../portfolio/portfolio.api'; } from '../../portfolio/portfolio.api';
import { StockPositionDto } from '../stocks/presentation/stock.dto'; import { StockPositionDto } from '../stocks/presentation/stock.dto';
const PRICE_DECIMAL_PLACES = 9;
const RETURN_PERCENT_DECIMAL_PLACES = 6;
export function instrumentPosition( export function instrumentPosition(
portfolio: PortfolioDto, portfolio: PortfolioDto,
uid: string, uid: string,
@@ -41,7 +44,11 @@ export function instrumentPosition(
? { ? {
currency: totalCost.currency, currency: totalCost.currency,
value: formatDecimal( value: formatDecimal(
divide(decimalFromString(totalCost.value), quantity, 9), divide(
decimalFromString(totalCost.value),
quantity,
PRICE_DECIMAL_PLACES,
),
), ),
} }
: null, : null,
@@ -103,7 +110,7 @@ function percent(
divide( divide(
multiply(decimalFromString(result.value), decimalFromString('100')), multiply(decimalFromString(result.value), decimalFromString('100')),
decimalFromString(base.value), decimalFromString(base.value),
6, RETURN_PERCENT_DECIMAL_PLACES,
), ),
); );
} }
@@ -19,6 +19,8 @@ import { BondDto, BondSearchResultDto } from '../presentation/bond.dto';
type IdentifiedBond = TbankBond & { uid: string }; type IdentifiedBond = TbankBond & { uid: string };
import { money } from '../infrastructure/bond-money.mapper'; import { money } from '../infrastructure/bond-money.mapper';
const SEARCH_RESULTS_LIMIT = 30;
@Injectable() @Injectable()
export class BondCatalogService { export class BondCatalogService {
private readonly cache = new LRUCache<string, Promise<object>>({ private readonly cache = new LRUCache<string, Promise<object>>({
@@ -33,7 +35,7 @@ export class BondCatalogService {
return this.cached(userId, `search:${query}`, async (connection) => { return this.cached(userId, `search:${query}`, async (connection) => {
const { token } = connection; const { token } = connection;
const found = await this.tbank.findBonds(token, query); const found = await this.tbank.findBonds(token, query);
const bonds = selectBonds(found, query).slice(0, 30); const bonds = selectBonds(found, query).slice(0, SEARCH_RESULTS_LIMIT);
if (!bonds.length) return []; if (!bonds.length) return [];
// Batch prices and share metadata with detail sections. Resolve readable // Batch prices and share metadata with detail sections. Resolve readable
// aliases only when an issue is opened, not for every search result. // aliases only when an issue is opened, not for every search result.
@@ -27,6 +27,10 @@ import {
import { BondMarketService } from './bond-market.service'; import { BondMarketService } from './bond-market.service';
import { money } from '../infrastructure/bond-money.mapper'; import { money } from '../infrastructure/bond-money.mapper';
import { BondCatalogService } from './bond-catalog.service'; import { BondCatalogService } from './bond-catalog.service';
const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5;
const COUPON_YIELD_DECIMAL_PLACES = 4;
@Injectable() @Injectable()
export class BondIncomeService { export class BondIncomeService {
constructor( constructor(
@@ -42,8 +46,8 @@ export class BondIncomeService {
const instrument = await this.catalog.bond(userId, connection, uid); const instrument = await this.catalog.bond(userId, connection, uid);
const { ticker, isin, classCode } = instrument; const { ticker, isin, classCode } = instrument;
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
const unavailable: string[] = []; const unavailable: string[] = [];
const [events, quote, effectiveYield, creditRating] = await Promise.all([ const [events, quote, effectiveYield, creditRating] = await Promise.all([
optional( optional(
@@ -94,7 +98,7 @@ export class BondIncomeService {
decimalFromString(String(couponPeriodDays)), decimalFromString(String(couponPeriodDays)),
decimalFromString(clean.value), decimalFromString(clean.value),
), ),
4, COUPON_YIELD_DECIMAL_PLACES,
), ),
) )
: null; : null;
@@ -137,8 +141,8 @@ export class BondIncomeService {
return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => { return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => {
await this.catalog.bond(userId, connection, uid); await this.catalog.bond(userId, connection, uid);
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
const unavailable: string[] = []; const unavailable: string[] = [];
const [events, position] = await Promise.all([ const [events, position] = await Promise.all([
this.couponEvents(userId, connection, uid, from, to), this.couponEvents(userId, connection, uid, from, to),
@@ -24,6 +24,12 @@ const dayMs = 86_400_000;
import { money } from '../infrastructure/bond-money.mapper'; import { money } from '../infrastructure/bond-money.mapper';
import { BondCatalogService } from './bond-catalog.service'; import { BondCatalogService } from './bond-catalog.service';
const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20;
const HALF_YEAR_MONTHS = 6;
const YEAR_MONTHS = 12;
const CANDLE_WINDOW_DAYS = 365;
const PRICE_DECIMAL_PLACES = 9;
@Injectable() @Injectable()
export class BondMarketService { export class BondMarketService {
constructor( constructor(
@@ -66,7 +72,7 @@ export class BondMarketService {
decimalFromString(nominal.value), decimalFromString(nominal.value),
), ),
decimalFromString('100'), decimalFromString('100'),
9, PRICE_DECIMAL_PLACES,
), ),
), ),
} }
@@ -90,7 +96,9 @@ export class BondMarketService {
await this.tbank.getCandles( await this.tbank.getCandles(
token, token,
uid, uid,
new Date(Date.parse(priceAt) - 20 * dayMs), new Date(
Date.parse(priceAt) - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs,
),
new Date(priceAt), new Date(priceAt),
), ),
priceAt, priceAt,
@@ -134,7 +142,11 @@ export class BondMarketService {
const from = new Date(to); const from = new Date(to);
from.setUTCMonth( from.setUTCMonth(
from.getUTCMonth() - from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12), (period === 'month'
? 1
: period === 'halfYear'
? HALF_YEAR_MONTHS
: YEAR_MONTHS),
); );
if (period === 'all') { if (period === 'all') {
if (!historyFrom) if (!historyFrom)
@@ -147,7 +159,10 @@ export class BondMarketService {
const collected = new Map<string, BondCandlesDto['candles'][number]>(); const collected = new Map<string, BondCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits. // Conservative one-year windows also work with older deployed RPC limits.
for (let start = from.valueOf(); start < to.valueOf();) { for (let start = from.valueOf(); start < to.valueOf();) {
const end = Math.min(start + 365 * dayMs, to.valueOf()); const end = Math.min(
start + CANDLE_WINDOW_DAYS * dayMs,
to.valueOf(),
);
for (const candle of await this.tbank.getCandles( for (const candle of await this.tbank.getCandles(
token, token,
uid, uid,
@@ -15,6 +15,8 @@ import { decimal } from '../../../../../integrations/tbank/decimal';
import { optional, timestamp } from '../../application/instrument-market'; import { optional, timestamp } from '../../application/instrument-market';
import { StockDto, StockSearchResultDto } from '../presentation/stock.dto'; import { StockDto, StockSearchResultDto } from '../presentation/stock.dto';
const SEARCH_RESULTS_LIMIT = 30;
type IdentifiedShare = TbankShare & { uid: string }; type IdentifiedShare = TbankShare & { uid: string };
@Injectable() @Injectable()
@@ -30,7 +32,7 @@ export class StockCatalogService {
search(userId: string, query: string): Promise<StockSearchResultDto[]> { search(userId: string, query: string): Promise<StockSearchResultDto[]> {
return this.cached(userId, `search:${query}`, async (token) => { return this.cached(userId, `search:${query}`, async (token) => {
const found = await this.tbank.findShares(token, query); const found = await this.tbank.findShares(token, query);
const shares = selectShares(found, query).slice(0, 30); const shares = selectShares(found, query).slice(0, SEARCH_RESULTS_LIMIT);
if (!shares.length) return []; if (!shares.length) return [];
// One market-data request for the list; failed enrichment must not hide a catalog result. // One market-data request for the list; failed enrichment must not hide a catalog result.
const [details, prices] = await Promise.all([ const [details, prices] = await Promise.all([
@@ -20,6 +20,9 @@ import {
} from '../presentation/stock.dto'; } from '../presentation/stock.dto';
import { StockCatalogService } from './stock-catalog.service'; import { StockCatalogService } from './stock-catalog.service';
const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5;
@Injectable() @Injectable()
export class StockIncomeService { export class StockIncomeService {
constructor( constructor(
@@ -36,8 +39,8 @@ export class StockIncomeService {
return this.catalog.cached(userId, `dividends:${uid}`, async (token) => { return this.catalog.cached(userId, `dividends:${uid}`, async (token) => {
await this.catalog.share(token, uid); await this.catalog.share(token, uid);
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
const unavailable: string[] = []; const unavailable: string[] = [];
const [events, position] = await Promise.all([ const [events, position] = await Promise.all([
this.tbank.getDividends(token, uid, from, to), this.tbank.getDividends(token, uid, from, to),
@@ -22,6 +22,12 @@ import {
const dayMs = 86_400_000; const dayMs = 86_400_000;
import { StockCatalogService } from './stock-catalog.service'; import { StockCatalogService } from './stock-catalog.service';
const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20;
const HALF_YEAR_MONTHS = 6;
const YEAR_MONTHS = 12;
const CANDLE_WINDOW_DAYS = 365;
const CHANGE_PERCENT_DECIMAL_PLACES = 6;
@Injectable() @Injectable()
export class StockMarketService { export class StockMarketService {
constructor( constructor(
@@ -58,7 +64,9 @@ export class StockMarketService {
await this.tbank.getCandles( await this.tbank.getCandles(
token, token,
uid, uid,
new Date(end.valueOf() - 20 * dayMs), new Date(
end.valueOf() - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs,
),
end, end,
), ),
priceAt, priceAt,
@@ -97,7 +105,7 @@ export class StockMarketService {
divide( divide(
multiply(difference, decimalFromString('100')), multiply(difference, decimalFromString('100')),
decimalFromString(previousPrice.value), decimalFromString(previousPrice.value),
6, CHANGE_PERCENT_DECIMAL_PLACES,
), ),
) )
: null, : null,
@@ -121,7 +129,11 @@ export class StockMarketService {
const from = new Date(to); const from = new Date(to);
from.setUTCMonth( from.setUTCMonth(
from.getUTCMonth() - from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12), (period === 'month'
? 1
: period === 'halfYear'
? HALF_YEAR_MONTHS
: YEAR_MONTHS),
); );
if (period === 'all') { if (period === 'all') {
if (!historyFrom) if (!historyFrom)
@@ -134,7 +146,10 @@ export class StockMarketService {
const collected = new Map<string, StockCandlesDto['candles'][number]>(); const collected = new Map<string, StockCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits. // Conservative one-year windows also work with older deployed RPC limits.
for (let start = from.valueOf(); start < to.valueOf();) { for (let start = from.valueOf(); start < to.valueOf();) {
const end = Math.min(start + 365 * dayMs, to.valueOf()); const end = Math.min(
start + CANDLE_WINDOW_DAYS * dayMs,
to.valueOf(),
);
for (const candle of await this.tbank.getCandles( for (const candle of await this.tbank.getCandles(
token, token,
uid, uid,
@@ -11,25 +11,27 @@ import {
import { MoneyDto } from '../../../../../common/money.dto'; import { MoneyDto } from '../../../../../common/money.dto';
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
const MAX_INSTRUMENT_QUERY_LENGTH = 80;
export class StockSearchQueryDto { export class StockSearchQueryDto {
@ApiProperty({ minLength: 2, maxLength: 80 }) @ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
@Transform(({ value }: { value: unknown }) => @Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value, typeof value === 'string' ? value.trim() : value,
) )
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@MaxLength(80) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
declare query: string; declare query: string;
} }
export class StockTickerParamsDto { export class StockTickerParamsDto {
@ApiProperty({ @ApiProperty({
minLength: 1, minLength: 1,
maxLength: 80, maxLength: MAX_INSTRUMENT_QUERY_LENGTH,
pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$', pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$',
}) })
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(80) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/) @Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
declare ticker: string; declare ticker: string;
} }
+21 -2
View File
@@ -1,3 +1,10 @@
const MOSCOW_UTC_OFFSET_HOURS = 3;
const MOSCOW_DAY_END_UTC = {
hours: 20,
minutes: 59,
seconds: 59,
milliseconds: 999,
};
export type DateOnlyRange = { from: string; to: string }; export type DateOnlyRange = { from: string; to: string };
const moscow = 'Europe/Moscow'; const moscow = 'Europe/Moscow';
export function moscowDate(value: Date): string { export function moscowDate(value: Date): string {
@@ -14,9 +21,21 @@ export function moscowDate(value: Date): string {
export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } { export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } {
const from = dateParts(range.from); const from = dateParts(range.from);
const end = dateParts(range.to); const end = dateParts(range.to);
const to = new Date(Date.UTC(end[0], end[1] - 1, end[2], 20, 59, 59, 999)); const to = new Date(
Date.UTC(
end[0],
end[1] - 1,
end[2],
MOSCOW_DAY_END_UTC.hours,
MOSCOW_DAY_END_UTC.minutes,
MOSCOW_DAY_END_UTC.seconds,
MOSCOW_DAY_END_UTC.milliseconds,
),
);
return { return {
from: new Date(Date.UTC(from[0], from[1] - 1, from[2], -3)), from: new Date(
Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS),
),
to: to > new Date() ? new Date() : to, to: to > new Date() ? new Date() : to,
}; };
} }
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
@@ -15,6 +16,7 @@ import { AccountReferenceDto } from '../../account-reference.dto';
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar'; import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
import { import {
BrokerageOperationDto, BrokerageOperationDto,
DEFAULT_OPERATIONS_PAGE_SIZE,
BrokerageOperationsPageDto, BrokerageOperationsPageDto,
BrokerageOperationsQueryDto, BrokerageOperationsQueryDto,
} from '../presentation/brokerage-operation.dto'; } from '../presentation/brokerage-operation.dto';
@@ -23,6 +25,8 @@ import {
operationCategory, operationCategory,
} from '../infrastructure/brokerage-operation.mapper'; } from '../infrastructure/brokerage-operation.mapper';
const CONTINUATION_TTL_MS = 300_000;
type OperationStream = { type OperationStream = {
account: AccountReferenceDto; account: AccountReferenceDto;
pending: BrokerageOperationDto[]; pending: BrokerageOperationDto[];
@@ -54,7 +58,7 @@ export class BrokerageOperationsService {
AllOperationsContinuation AllOperationsContinuation
>({ >({
max: 100, max: 100,
ttl: 5 * 60_000, ttl: CONTINUATION_TTL_MS,
ttlAutopurge: true, ttlAutopurge: true,
}); });
@@ -86,7 +90,10 @@ export class BrokerageOperationsService {
query.instrumentUid && !query.from query.instrumentUid && !query.from
? { ? {
...range, ...range,
from: new Date(Number(account.openedDate?.seconds ?? 0) * 1000), from: new Date(
Number(account.openedDate?.seconds ?? 0) *
MILLISECONDS_PER_SECOND,
),
} }
: range, : range,
), ),
@@ -108,7 +115,7 @@ export class BrokerageOperationsService {
from: query.from ?? null, from: query.from ?? null,
to: query.to ?? null, to: query.to ?? null,
categories: [...new Set(query.categories ?? [])].sort(), categories: [...new Set(query.categories ?? [])].sort(),
limit: query.limit ?? 100, limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE,
}); });
const continuation = query.cursor const continuation = query.cursor
? this.continuations.get(query.cursor) ? this.continuations.get(query.cursor)
@@ -178,7 +185,7 @@ export class BrokerageOperationsService {
): Promise<BrokerageOperationsPageDto> { ): Promise<BrokerageOperationsPageDto> {
const items: BrokerageOperationDto[] = []; const items: BrokerageOperationDto[] = [];
// Each stream retains unconsumed rows. A page boundary never discards them. // Each stream retains unconsumed rows. A page boundary never discards them.
while (items.length < (query.limit ?? 100)) { while (items.length < (query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE)) {
for (const stream of streams) { for (const stream of streams) {
while (!stream.pending.length && !stream.finished) { while (!stream.pending.length && !stream.finished) {
const page = await this.page( const page = await this.page(
@@ -324,7 +331,9 @@ export class BrokerageOperationsService {
accountId, accountId,
async ({ token, generation, account }) => { async ({ token, generation, account }) => {
const accountOpenedAt = account.openedDate?.seconds const accountOpenedAt = account.openedDate?.seconds
? new Date(Number(account.openedDate.seconds) * 1000) ? new Date(
Number(account.openedDate.seconds) * MILLISECONDS_PER_SECOND,
)
: undefined; : undefined;
const selected = const selected =
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges; typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges;
@@ -395,7 +404,7 @@ export class BrokerageOperationsService {
from: seconds(range.from), from: seconds(range.from),
to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')), to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')),
cursor: query.cursor, cursor: query.cursor,
limit: query.limit ?? 100, limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE,
state: 'OPERATION_STATE_EXECUTED', state: 'OPERATION_STATE_EXECUTED',
...(query.instrumentUid ? { instrumentId: query.instrumentUid } : {}), ...(query.instrumentUid ? { instrumentId: query.instrumentUid } : {}),
}); });
@@ -480,7 +489,7 @@ function cacheKey(
categories, categories,
instrumentUid: query.instrumentUid ?? null, instrumentUid: query.instrumentUid ?? null,
cursor: query.cursor ?? null, cursor: query.cursor ?? null,
limit: query.limit ?? 100, limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE,
}); });
} }
@@ -493,7 +502,7 @@ function dateBoundary(value: string | undefined, end: boolean): Date | null {
function seconds(value: Date, endOfSecond = false) { function seconds(value: Date, endOfSecond = false) {
return { return {
seconds: Math.floor(value.valueOf() / 1000).toString(), seconds: Math.floor(value.valueOf() / MILLISECONDS_PER_SECOND).toString(),
...(endOfSecond ? { nanos: 999_999_999 } : {}), ...(endOfSecond ? { nanos: 999_999_999 } : {}),
}; };
} }
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { formatDecimal } from '../../../../common/decimal'; import { formatDecimal } from '../../../../common/decimal';
import { import {
TbankMoney, TbankMoney,
@@ -84,5 +85,7 @@ function money(value: TbankMoney | undefined) {
function timestamp( function timestamp(
value: { seconds?: string | number; nanos?: number } | undefined, value: { seconds?: string | number; nanos?: number } | undefined,
) { ) {
return new Date(Number(value?.seconds ?? 0) * 1000).toISOString(); return new Date(
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND,
).toISOString();
} }
@@ -16,6 +16,10 @@ import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto'; import { AccountReferenceDto } from '../../account-reference.dto';
const MAX_CURSOR_LENGTH = 4096;
const MAX_OPERATIONS_PAGE_SIZE = 1000;
export const DEFAULT_OPERATIONS_PAGE_SIZE = 100;
export const operationCategories = [ export const operationCategories = [
'trade', 'trade',
'income', 'income',
@@ -58,15 +62,19 @@ export class BrokerageOperationsQueryDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(4096) @MaxLength(MAX_CURSOR_LENGTH)
cursor?: string; cursor?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 }) @ApiPropertyOptional({
minimum: 1,
maximum: MAX_OPERATIONS_PAGE_SIZE,
default: DEFAULT_OPERATIONS_PAGE_SIZE,
})
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
@Max(1000) @Max(MAX_OPERATIONS_PAGE_SIZE)
limit?: number; limit?: number;
} }
@@ -6,6 +6,9 @@ import {
import type { Money } from '../../../../common/money.types'; import type { Money } from '../../../../common/money.types';
const SHARE_PERCENT_DECIMAL_PLACES = 6;
const PERCENT_SCALED_FACTOR = 100_000_000n;
const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const; const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const;
// Build from positions and cash, never from totals (which already include cash). // Build from positions and cash, never from totals (which already include cash).
@@ -59,10 +62,10 @@ export function portfolioStructure(
value: value:
(value.value * (value.value *
10n ** BigInt(total.scale - value.scale) * 10n ** BigInt(total.scale - value.scale) *
100_000_000n + PERCENT_SCALED_FACTOR +
total.value / 2n) / total.value / 2n) /
total.value, total.value,
scale: 6, scale: SHARE_PERCENT_DECIMAL_PLACES,
}) })
: null, : null,
})); }));
@@ -4,6 +4,7 @@ import {
Delete, Delete,
Get, Get,
HttpCode, HttpCode,
HttpStatus,
Put, Put,
Req, Req,
UseGuards, UseGuards,
@@ -47,7 +48,7 @@ export class TbankConnectionController {
} }
@Delete() @Delete()
@HttpCode(204) @HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse() @ApiNoContentResponse()
delete(@Req() request: Request & { user: AccessPayload }): Promise<void> { delete(@Req() request: Request & { user: AccessPayload }): Promise<void> {
return this.connection.delete(request.user.sub); return this.connection.delete(request.user.sub);
@@ -1,11 +1,13 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
const MAX_TOKEN_LENGTH = 4096;
export class PutTbankConnectionDto { export class PutTbankConnectionDto {
@ApiProperty({ writeOnly: true }) @ApiProperty({ writeOnly: true })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(4096) @MaxLength(MAX_TOKEN_LENGTH)
declare token: string; declare token: string;
} }
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../common/time';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { import {
add, add,
@@ -25,12 +26,14 @@ import {
UpcomingEventsQueryDto, UpcomingEventsQueryDto,
} from './upcoming-events.dto'; } from './upcoming-events.dto';
const DEFAULT_EVENT_WINDOW_DAYS = 30;
type Position = NonNullable<TbankPortfolio['positions']>[number]; type Position = NonNullable<TbankPortfolio['positions']>[number];
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> }; type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
const positionsBatchSize = 5; const positionsBatchSize = 5;
const dividendCacheTtlMs = 15 * 60_000; const dividendCacheTtlMs = 900_000;
const instrumentCacheTtlMs = 24 * 60 * 60_000; const instrumentCacheTtlMs = 86_400_000;
@Injectable() @Injectable()
export class UpcomingEventsService { export class UpcomingEventsService {
@@ -242,7 +245,9 @@ export class UpcomingEventsService {
return { return {
...metadata, ...metadata,
type: 'dividend', type: 'dividend',
date: new Date(Number(event.recordDate?.seconds) * 1000) date: new Date(
Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND,
)
.toISOString() .toISOString()
.slice(0, 10), .slice(0, 10),
dateKind: 'registry', dateKind: 'registry',
@@ -287,7 +292,7 @@ function supported(position: Position): boolean {
function dateWindow(query: UpcomingEventsQueryDto) { function dateWindow(query: UpcomingEventsQueryDto) {
const from = query.from ?? new Date().toISOString().slice(0, 10); const from = query.from ?? new Date().toISOString().slice(0, 10);
const end = new Date(`${from}T00:00:00.000Z`); const end = new Date(`${from}T00:00:00.000Z`);
end.setUTCDate(end.getUTCDate() + 30); end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS);
const to = query.to ?? end.toISOString().slice(0, 10); const to = query.to ?? end.toISOString().slice(0, 10);
if (from > to) if (from > to)
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' }); throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });