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,4 +1,4 @@
import { applyDecorators } from '@nestjs/common';
import { HttpStatus, applyDecorators } from '@nestjs/common';
import {
ApiExtraModels,
ApiProperty,
@@ -35,10 +35,13 @@ function problemResponse(status: number, description: string) {
export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
return applyDecorators(
ApiExtraModels(ProblemDetailsDto),
problemResponse(400, 'Invalid request'),
problemResponse(401, 'Authentication required'),
problemResponse(404, 'Resource not found'),
problemResponse(503, 'Upstream service unavailable'),
problemResponse(500, 'Internal server error'),
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
problemResponse(HttpStatus.UNAUTHORIZED, 'Authentication required'),
problemResponse(HttpStatus.NOT_FOUND, 'Resource not found'),
problemResponse(
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 type { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
@@ -46,11 +47,11 @@ export class RequestLoggingMiddleware implements NestMiddleware {
: {}),
};
const level =
response.statusCode >= 500
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
? 'error'
: response.statusCode === 429 ||
response.statusCode === 401 ||
response.statusCode === 403
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
? 'warn'
: 'info';
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 {
DATABASE_URL: string;
FRONTEND_ORIGIN: string;
@@ -13,12 +18,14 @@ export interface Environment {
export function validateEnv(config: Record<string, unknown>): Environment {
const databaseUrl = config['DATABASE_URL'];
const frontendOrigin = config['FRONTEND_ORIGIN'];
const port = config['PORT'] ?? 3000;
const port = config['PORT'] ?? DEFAULT_PORT;
const jwtSecret = config['JWT_SECRET'];
const tokenEncryptionKey = config['TOKEN_ENCRYPTION_KEY'];
const tbankEndpoint =
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 logLevel = config['LOG_LEVEL'] ?? 'info';
@@ -47,18 +54,25 @@ export function validateEnv(config: Record<string, unknown>): Environment {
}
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');
}
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');
}
if (
typeof tokenEncryptionKey !== 'string' ||
!/^[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(
'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';
const FALLBACK_PAGE_SIZE = 100;
const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000;
@@ -121,8 +124,8 @@ export class MoexClient {
if (!paginated) return rows;
const cursorBlock = response[`${block}.cursor`];
if (!cursorBlock) {
if (current.length < 100) return rows;
start += 100;
if (current.length < FALLBACK_PAGE_SIZE) return rows;
start += FALLBACK_PAGE_SIZE;
continue;
}
const cursor = parseRows(cursorBlock)[0];
@@ -145,7 +148,7 @@ export class MoexClient {
const response = await fetch(url, {
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}`);
const body: unknown = await response.json();
if (!body || typeof body !== 'object')
+15 -9
View File
@@ -2,6 +2,12 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio';
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/';
export type NcrRating = {
agency: 'NCR';
@@ -18,7 +24,7 @@ export class NcrRatingsClient {
private readonly cache = new LRUCache<
string,
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> {
const key = isin.trim().toUpperCase();
@@ -38,7 +44,7 @@ export class NcrRatingsClient {
private async registry(): Promise<Map<string, NcrRating>> {
const response = await fetch(registryUrl, {
signal: AbortSignal.timeout(10_000),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) throw new Error('NCR unavailable');
const $ = load(await response.text());
@@ -49,18 +55,18 @@ export class NcrRatingsClient {
.get();
if (
table.length !== 1 ||
headings.length !== 6 ||
headings.length !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' ||
headings[3] !== 'ISIN' ||
headings[5] !== 'Дата'
headings[ISIN_COLUMN] !== 'ISIN' ||
headings[DATE_COLUMN] !== 'Дата'
)
throw new Error('NCR registry changed');
const observedAt = new Date().toISOString();
const result = new Map<string, NcrRating>();
table.find('tbody tr').each((_, row) => {
const cells = $(row).find('td');
if (cells.length !== 6) throw new Error('NCR row changed');
const isin = cells.eq(3).text().trim().toUpperCase();
if (cells.length !== COLUMN_COUNT) throw new Error('NCR row changed');
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();
@@ -73,9 +79,9 @@ export class NcrRatingsClient {
)
throw new Error('NCR rating changed');
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))
throw new Error('NCR identity missing');
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { TbankAccount } from './tbank.client';
const accountTypes = {
@@ -51,7 +52,7 @@ export function accessibleTbankAccount(
function openedOn(seconds: string | number | undefined): string | 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;
return date.toISOString().slice(0, 10);
}
+25 -8
View File
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import {
ChannelCredentials,
Client,
@@ -28,6 +29,13 @@ import type {
TbankShare,
} 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;
type Unary<Request, Response> = (
@@ -134,7 +142,7 @@ class RateLimiter {
private drain(): void {
const now = Date.now();
if (now - this.windowStartedAt >= 1000) {
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
this.windowStartedAt = now;
this.starts = 0;
}
@@ -145,7 +153,7 @@ class RateLimiter {
if (this.waiting.length && this.starts === this.perSecond) {
setTimeout(
() => 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',
{
instrumentId: uid,
from: { seconds: String(Math.floor(from.valueOf() / 1000)) },
to: { seconds: String(Math.floor(to.valueOf() / 1000)) },
from: {
seconds: String(Math.floor(from.valueOf() / MILLISECONDS_PER_SECOND)),
},
to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
},
interval: 'CANDLE_INTERVAL_DAY',
limit: 2400,
},
@@ -456,10 +468,10 @@ export class TbankClient {
request: object,
): Promise<Response> {
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) {
const startedAt = performance.now();
const queueFull = this.queue.size >= 50;
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try {
if (queueFull)
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
@@ -492,7 +504,12 @@ export class TbankClient {
durationMs: Math.round(performance.now() - startedAt),
});
await new Promise((resolve) =>
setTimeout(resolve, metadataCall ? attempts * 2000 : 1000),
setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
),
);
continue;
}
@@ -537,5 +554,5 @@ export class TbankClient {
}
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';
const KEY_SIZE_BYTES = 32;
const VERSION = 1;
const NONCE_SIZE = 12;
const TAG_SIZE = 16;
@@ -9,7 +11,8 @@ export class TbankCrypto {
static fromBase64(key: string): TbankCrypto {
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);
}
+5 -2
View File
@@ -10,6 +10,9 @@ import { Session, User } from '../../../../generated/prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types';
const PASSWORD_HASH_ROUNDS = 12;
const REFRESH_TTL_MS = 604_800_000;
const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d';
const DUMMY_PASSWORD_HASH =
@@ -39,7 +42,7 @@ export class AuthService {
data: {
id: randomUUID(),
email: normalizedEmail,
passwordHash: await bcrypt.hash(password, 12),
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
},
});
} catch (error) {
@@ -162,7 +165,7 @@ export class AuthService {
);
const data = {
refreshTokenHash: this.hash(refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
};
if (expectedRefreshTokenHash) {
const updated = await this.prisma.session.updateMany({
@@ -3,6 +3,7 @@ import {
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Req,
Res,
@@ -49,7 +50,7 @@ export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('register')
@HttpCode(200)
@HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto })
async register(
@Body() credentials: CredentialsDto,
@@ -62,7 +63,7 @@ export class AuthController {
}
@Post('login')
@HttpCode(200)
@HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto })
async login(
@Body() credentials: CredentialsDto,
@@ -75,7 +76,7 @@ export class AuthController {
}
@Post('refresh')
@HttpCode(200)
@HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto })
async refresh(
@Req() request: Request,
@@ -92,7 +93,7 @@ export class AuthController {
}
@Post('logout')
@HttpCode(204)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse()
async logout(
@Req() request: Request,
+19 -6
View File
@@ -7,20 +7,26 @@ import {
ValidatorConstraint,
} 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' })
class EmailConstraint {
validate(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length <= 254 &&
value.length <= MAX_EMAIL_LENGTH &&
value.split('@').length === 2 &&
!value
.split('')
.some(
(character) =>
/\s/.test(character) ||
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127,
character.charCodeAt(0) < ASCII_SPACE ||
character.charCodeAt(0) === ASCII_DELETE,
) &&
value.split('@').every(Boolean)
);
@@ -33,7 +39,10 @@ class EmailConstraint {
@ValidatorConstraint({ name: 'passwordBytes' })
class PasswordBytesConstraint {
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 {
return 'must not exceed 72 UTF-8 bytes';
@@ -49,9 +58,13 @@ export class CredentialsDto {
@Validate(EmailConstraint)
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()
@MinLength(12)
@MinLength(MIN_PASSWORD_LENGTH)
@Validate(PasswordBytesConstraint)
declare password: string;
}
@@ -1,3 +1,7 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import {
BadRequestException,
ConflictException,
@@ -13,7 +17,26 @@ import {
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
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 });
}
export function periodBounds(query: BankPeriodQueryDto) {
@@ -35,11 +58,13 @@ export function periodBounds(query: BankPeriodQueryDto) {
return fail();
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 });
}
export function missing(): never {
throw new NotFoundException({ code: 'BANK_NOT_FOUND' });
throw new NotFoundException({ code: BankErrorCode.NotFound });
}
export const fingerprint = (value: unknown) =>
createHash('sha256')
@@ -56,16 +81,16 @@ export function amount(
try {
return parseRubMoney(value, positive);
} catch {
return fail('BANK_INVALID_MONEY');
return fail(BankErrorCode.InvalidMoney);
}
}
export function calendar(value: string) {
if (!isCalendarDate(value)) fail('BANK_INVALID_DATE');
if (!isCalendarDate(value)) fail(BankErrorCode.InvalidDate);
return value;
}
export function currentDate(value: string) {
calendar(value);
if (value > moscowToday()) fail('BANK_INVALID_DATE');
if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
}
export function name(value: string, max: number) {
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) {
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.paymentSystem != null &&
@@ -83,7 +108,7 @@ export function cardInput(dto: CreateBankCardDto) {
fail();
return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, 100),
name: name(dto.name, MAX_CARD_NAME_LENGTH),
bankName,
lastFour: dto.lastFour || null,
openingBalanceKopecks: amount(dto.openingBalance),
@@ -93,7 +118,8 @@ export function cardInput(dto: CreateBankCardDto) {
export function operationInput(dto: CreateBankOperationDto) {
if (
dto.description != null &&
(typeof dto.description !== 'string' || dto.description.length > 500)
(typeof dto.description !== 'string' ||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
)
fail();
return {
@@ -11,6 +11,7 @@ import { rubMoney } from '../domain/rub-money';
import {
cardInput,
BankErrorCode,
conflict,
currentDate,
fail,
@@ -71,7 +72,7 @@ export class BankCardsService {
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT');
conflict(BankErrorCode.IntentConflict);
return this.cardView(tx, existing);
}
currentDate(input.openingDate);
@@ -110,7 +111,8 @@ export class BankCardsService {
const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt) conflict('BANK_CARD_ARCHIVED');
if (financial && previous.archivedAt)
conflict(BankErrorCode.CardArchived);
currentDate(input.openingDate);
if (
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({
where: { id, userId, version: dto.version },
data: {
@@ -1,3 +1,4 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { GoneException, Injectable } from '@nestjs/common';
import {
BankCardOperation,
@@ -6,6 +7,7 @@ import {
import { PrismaService } from '../../../../prisma/prisma.service';
import {
BankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
BankOperationsQueryDto,
BankPageQueryDto,
CreateBankOperationDto,
@@ -14,6 +16,7 @@ import {
import { rubMoney } from '../domain/rub-money';
import {
BankErrorCode,
conflict,
currentDate,
fail,
@@ -48,7 +51,7 @@ export class BankOperationsService {
})
: null;
const refunds =
row.kind === 'expense' || source
row.kind === BankOperationKind.Expense || source
? await tx.bankCardOperation.findMany({
where: {
userId: row.userId,
@@ -70,7 +73,7 @@ export class BankOperationsService {
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === 'expense' || source
row.kind === BankOperationKind.Expense || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
@@ -100,15 +103,15 @@ export class BankOperationsService {
(id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate
)
fail('BANK_INVALID_DATE');
fail(BankErrorCode.InvalidDate);
}
if (
old &&
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
)
fail('BANK_IMMUTABLE_OPERATION');
if (input.kind === 'transfer') {
fail(BankErrorCode.ImmutableOperation);
if (input.kind === BankOperationKind.Transfer) {
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
@@ -117,7 +120,7 @@ export class BankOperationsService {
)
fail();
} else if (input.targetCardId) fail();
if (input.kind === 'expense') {
if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail();
if (!input.categoryId) {
const category = await tx.expenseCategory.findUnique({
@@ -133,8 +136,9 @@ export class BankOperationsService {
)
missing();
} else if (input.categoryId) fail();
if (input.kind !== 'refund' && input.originalExpenseId) fail();
if (input.kind === 'refund') {
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId)
fail();
if (input.kind === BankOperationKind.Refund) {
if (!input.originalExpenseId) fail();
const source = await this.repository.operation(
tx,
@@ -143,10 +147,10 @@ export class BankOperationsService {
);
if (
source.deletedAt ||
source.kind !== 'expense' ||
source.kind !== BankOperationKind.Expense ||
source.cardId !== input.cardId
)
fail('BANK_INVALID_REFUND');
fail(BankErrorCode.InvalidRefund);
if (
(
await tx.bankCardOperation.updateMany({
@@ -169,15 +173,15 @@ export class BankOperationsService {
...(old ? { id: { not: old.id } } : {}),
},
});
if (input.date < source.date) fail('BANK_INVALID_DATE');
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
input.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({
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
@@ -188,7 +192,7 @@ export class BankOperationsService {
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
input.amountKopecks)
)
conflict('BANK_DEPENDENT_REFUNDS');
conflict(BankErrorCode.DependentRefunds);
}
}
createOperation(userId: string, dto: CreateBankOperationDto) {
@@ -201,9 +205,9 @@ export class BankOperationsService {
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT');
conflict(BankErrorCode.IntentConflict);
if (existing.deletedAt)
throw new GoneException({ code: 'BANK_OPERATION_DELETED' });
throw new GoneException({ code: BankErrorCode.OperationDeleted });
return this.operationView(tx, existing);
}
const input = { ...normalized };
@@ -265,12 +269,12 @@ export class BankOperationsService {
if (row.targetCardId)
await this.repository.card(tx, userId, row.targetCardId, true);
if (
row.kind === 'expense' &&
row.kind === BankOperationKind.Expense &&
(await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null },
}))
)
conflict('BANK_DEPENDENT_REFUNDS');
conflict(BankErrorCode.DependentRefunds);
if (row.originalExpenseId) {
const source = await this.repository.operation(
tx,
@@ -310,14 +314,14 @@ export class BankOperationsService {
return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id);
const page = query.page ?? 1,
limit = query.limit ?? 50;
limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE;
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: 'expense' }),
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
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 { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service';
@@ -24,7 +25,7 @@ export class ExpenseCategoriesService {
}));
}
createCategory(userId: string, value: string) {
const label = name(value, 80);
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => {
await this.repository.seed(tx, userId);
const row = await tx.expenseCategory.create({
@@ -44,7 +45,7 @@ export class ExpenseCategoriesService {
});
}
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) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
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 {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Moscow',
@@ -30,12 +33,21 @@ export function bankBalance(
let balance = opening;
for (const operation of operations) {
if (operation.cardId === cardId) {
if (operation.kind === 'expense' || operation.kind === 'transfer')
if (
operation.kind === BankOperationKind.Expense ||
operation.kind === BankOperationKind.Transfer
)
balance -= operation.amountKopecks;
else if (operation.kind === 'topup' || operation.kind === 'refund')
else if (
operation.kind === BankOperationKind.Topup ||
operation.kind === BankOperationKind.Refund
)
balance += operation.amountKopecks;
}
if (operation.kind === 'transfer' && operation.targetCardId === cardId)
if (
operation.kind === BankOperationKind.Transfer &&
operation.targetCardId === cardId
)
balance += operation.amountKopecks;
}
return balance;
@@ -60,13 +72,23 @@ export function bankSummary(
for (const operation of operations) {
if (
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
)
continue;
if (operation.kind !== 'expense' && operation.kind !== 'refund') continue;
const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n;
const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n;
if (
operation.kind !== BankOperationKind.Expense &&
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;
refunds += refund;
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 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 {
if (
money?.currency !== 'RUB' ||
@@ -12,7 +15,7 @@ export function parseRubMoney(money: Money, positive = false): bigint {
if (amount.scale > 2)
throw new RangeError('RUB money supports at most two decimal places');
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');
if (positive && kopecks <= 0n)
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 { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client';
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;
const TRANSACTION_MAX_WAIT_MS = 10_000;
const TRANSACTION_TIMEOUT_MS = 15_000;
const MAX_WRITE_RETRY_COUNT = 2;
const seeds = [
'Продукты',
'Кафе',
@@ -23,8 +31,8 @@ export class BankCardsRepository {
for (let attempt = 0; ; attempt++) {
try {
return await this.prisma.$transaction(run, {
maxWait: 10000,
timeout: 15000,
maxWait: TRANSACTION_MAX_WAIT_MS,
timeout: TRANSACTION_TIMEOUT_MS,
});
} catch (error) {
const code =
@@ -36,9 +44,10 @@ export class BankCardsRepository {
code === 'P2028' ||
(error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < 2) continue;
if (busy) conflict('BANK_WRITE_CONFLICT');
if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY');
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT)
continue;
if (busy) conflict(BankErrorCode.WriteConflict);
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
throw error;
}
}
@@ -46,7 +55,7 @@ export class BankCardsRepository {
async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing();
if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED');
if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
return card;
}
async operation(tx: Tx, userId: string, id: string) {
@@ -56,7 +65,7 @@ export class BankCardsRepository {
}
async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) {
const seedKey = index === 8 ? 'other' : String(index);
const seedKey = index === seeds.length - 1 ? 'other' : String(index);
if (
!(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } },
@@ -85,7 +94,7 @@ export class BankCardsRepository {
userId,
deletedAt: null,
date: { gte: period.from, lte: period.to },
kind: { in: ['expense', 'refund'] },
kind: { in: [BankOperationKind.Expense, BankOperationKind.Refund] },
...(cardId ? { cardId } : {}),
},
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 {
ApiProperty,
ApiPropertyOptional,
@@ -24,16 +30,13 @@ import {
} from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto';
export const DEFAULT_BANK_PAGE_SIZE = 50;
const MAX_BANK_PAGE_SIZE = 100;
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export const bankPaymentSystems = ['mir', 'visa', 'mastercard'] as const;
export const bankOperationKinds = [
'expense',
'topup',
'transfer',
'refund',
] as const;
export type BankOperationKind = (typeof bankOperationKinds)[number];
const bankOperationKinds = Object.values(BankOperationKind);
export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@@ -43,17 +46,21 @@ export class RubMoneyInputDto {
}
export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ minLength: 1, maxLength: 100 })
@ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(100)
@MaxLength(MAX_CARD_NAME_LENGTH)
declare name: string;
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 })
@ApiPropertyOptional({
type: String,
nullable: true,
maxLength: MAX_CARD_NAME_LENGTH,
})
@IsOptional()
@Transform(trim)
@IsString()
@MaxLength(100)
@MaxLength(MAX_CARD_NAME_LENGTH)
bankName?: string | null;
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
@IsOptional()
@@ -84,11 +91,11 @@ export class UpdateBankCardDto extends PartialType(
archived?: boolean;
}
export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: 80 })
@ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(80)
@MaxLength(MAX_CATEGORY_NAME_LENGTH)
declare name: string;
}
export class UpdateExpenseCategoryDto extends CategoryNameDto {
@@ -121,10 +128,10 @@ export class CreateBankOperationDto {
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
declare date: string;
@ApiPropertyOptional({ maxLength: 500 })
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsString()
@MaxLength(500)
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
description?: string;
}
export class UpdateBankOperationDto extends PartialType(
@@ -146,12 +153,16 @@ export class BankPageQueryDto {
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 })
@ApiPropertyOptional({
minimum: 1,
maximum: MAX_BANK_PAGE_SIZE,
default: DEFAULT_BANK_PAGE_SIZE,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@Max(MAX_BANK_PAGE_SIZE)
limit?: number;
}
export class BankPeriodQueryDto {
@@ -4,6 +4,7 @@ import {
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
@@ -143,7 +144,7 @@ export class BankCardsController {
return this.operations.updateOperation(req.user.sub, id, body);
}
@Delete('operations/:id')
@HttpCode(204)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse()
deleteOperation(
@Req() req: AuthRequest,
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_DAY } from '../../../common/time';
import { BadRequestException } from '@nestjs/common';
import {
DateOnlyRange,
@@ -6,6 +7,12 @@ import {
utcDate,
} 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 function planIncomePeriod(
@@ -36,9 +43,9 @@ export function planIncomePeriod(
previous,
yearAgo: yearAgoRange,
granularity:
rangeDays(selected) <= 45
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
? 'day'
: rangeDays(selected) <= 180
: rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
? 'week'
: 'month',
};
@@ -53,11 +60,19 @@ export function rangeBuckets(
const buckets: DateOnlyRange[] = [];
let cursor =
granularity === 'week'
? addDays(range.from, -((utcDate(range.from).getUTCDay() + 6) % 7))
: `${range.from.slice(0, 7)}-01`;
? addDays(
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) {
const end =
granularity === 'week' ? addDays(cursor, 6) : endOfMonth(cursor);
granularity === 'week'
? addDays(cursor, DAYS_PER_WEEK - 1)
: endOfMonth(cursor);
buckets.push({
from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end,
@@ -71,7 +86,7 @@ export function rangeDays(range: DateOnlyRange): number {
return (
Math.round(
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
86_400_000,
MILLISECONDS_PER_DAY,
) + 1
);
}
@@ -100,7 +115,7 @@ function dates(range: DateOnlyRange): 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 {
@@ -126,7 +141,7 @@ 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(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
? result
: null;
@@ -1,16 +1,21 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { ServiceUnavailableException } from '@nestjs/common';
import type {
TbankCandle,
TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api';
const MOSCOW_OFFSET_MS = 10_800_000;
export function timestamp(value?: TbankTimestamp): string | null {
const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0
? new Date(seconds * 1000).toISOString()
? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
: null;
}
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(
candles: TbankCandle[],
@@ -13,6 +13,9 @@ import {
} from '../../portfolio/portfolio.api';
import { StockPositionDto } from '../stocks/presentation/stock.dto';
const PRICE_DECIMAL_PLACES = 9;
const RETURN_PERCENT_DECIMAL_PLACES = 6;
export function instrumentPosition(
portfolio: PortfolioDto,
uid: string,
@@ -41,7 +44,11 @@ export function instrumentPosition(
? {
currency: totalCost.currency,
value: formatDecimal(
divide(decimalFromString(totalCost.value), quantity, 9),
divide(
decimalFromString(totalCost.value),
quantity,
PRICE_DECIMAL_PLACES,
),
),
}
: null,
@@ -103,7 +110,7 @@ function percent(
divide(
multiply(decimalFromString(result.value), decimalFromString('100')),
decimalFromString(base.value),
6,
RETURN_PERCENT_DECIMAL_PLACES,
),
);
}
@@ -19,6 +19,8 @@ import { BondDto, BondSearchResultDto } from '../presentation/bond.dto';
type IdentifiedBond = TbankBond & { uid: string };
import { money } from '../infrastructure/bond-money.mapper';
const SEARCH_RESULTS_LIMIT = 30;
@Injectable()
export class BondCatalogService {
private readonly cache = new LRUCache<string, Promise<object>>({
@@ -33,7 +35,7 @@ export class BondCatalogService {
return this.cached(userId, `search:${query}`, async (connection) => {
const { token } = connection;
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 [];
// Batch prices and share metadata with detail sections. Resolve readable
// aliases only when an issue is opened, not for every search result.
@@ -27,6 +27,10 @@ import {
import { BondMarketService } from './bond-market.service';
import { money } from '../infrastructure/bond-money.mapper';
import { BondCatalogService } from './bond-catalog.service';
const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5;
const COUPON_YIELD_DECIMAL_PLACES = 4;
@Injectable()
export class BondIncomeService {
constructor(
@@ -42,8 +46,8 @@ export class BondIncomeService {
const instrument = await this.catalog.bond(userId, connection, uid);
const { ticker, isin, classCode } = instrument;
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${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, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
const unavailable: string[] = [];
const [events, quote, effectiveYield, creditRating] = await Promise.all([
optional(
@@ -94,7 +98,7 @@ export class BondIncomeService {
decimalFromString(String(couponPeriodDays)),
decimalFromString(clean.value),
),
4,
COUPON_YIELD_DECIMAL_PLACES,
),
)
: null;
@@ -137,8 +141,8 @@ export class BondIncomeService {
return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => {
await this.catalog.bond(userId, connection, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${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, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.couponEvents(userId, connection, uid, from, to),
@@ -24,6 +24,12 @@ const dayMs = 86_400_000;
import { money } from '../infrastructure/bond-money.mapper';
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()
export class BondMarketService {
constructor(
@@ -66,7 +72,7 @@ export class BondMarketService {
decimalFromString(nominal.value),
),
decimalFromString('100'),
9,
PRICE_DECIMAL_PLACES,
),
),
}
@@ -90,7 +96,9 @@ export class BondMarketService {
await this.tbank.getCandles(
token,
uid,
new Date(Date.parse(priceAt) - 20 * dayMs),
new Date(
Date.parse(priceAt) - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs,
),
new Date(priceAt),
),
priceAt,
@@ -134,7 +142,11 @@ export class BondMarketService {
const from = new Date(to);
from.setUTCMonth(
from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
(period === 'month'
? 1
: period === 'halfYear'
? HALF_YEAR_MONTHS
: YEAR_MONTHS),
);
if (period === 'all') {
if (!historyFrom)
@@ -147,7 +159,10 @@ export class BondMarketService {
const collected = new Map<string, BondCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits.
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(
token,
uid,
@@ -15,6 +15,8 @@ import { decimal } from '../../../../../integrations/tbank/decimal';
import { optional, timestamp } from '../../application/instrument-market';
import { StockDto, StockSearchResultDto } from '../presentation/stock.dto';
const SEARCH_RESULTS_LIMIT = 30;
type IdentifiedShare = TbankShare & { uid: string };
@Injectable()
@@ -30,7 +32,7 @@ export class StockCatalogService {
search(userId: string, query: string): Promise<StockSearchResultDto[]> {
return this.cached(userId, `search:${query}`, async (token) => {
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 [];
// One market-data request for the list; failed enrichment must not hide a catalog result.
const [details, prices] = await Promise.all([
@@ -20,6 +20,9 @@ import {
} from '../presentation/stock.dto';
import { StockCatalogService } from './stock-catalog.service';
const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5;
@Injectable()
export class StockIncomeService {
constructor(
@@ -36,8 +39,8 @@ export class StockIncomeService {
return this.catalog.cached(userId, `dividends:${uid}`, async (token) => {
await this.catalog.share(token, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${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, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.tbank.getDividends(token, uid, from, to),
@@ -22,6 +22,12 @@ import {
const dayMs = 86_400_000;
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()
export class StockMarketService {
constructor(
@@ -58,7 +64,9 @@ export class StockMarketService {
await this.tbank.getCandles(
token,
uid,
new Date(end.valueOf() - 20 * dayMs),
new Date(
end.valueOf() - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs,
),
end,
),
priceAt,
@@ -97,7 +105,7 @@ export class StockMarketService {
divide(
multiply(difference, decimalFromString('100')),
decimalFromString(previousPrice.value),
6,
CHANGE_PERCENT_DECIMAL_PLACES,
),
)
: null,
@@ -121,7 +129,11 @@ export class StockMarketService {
const from = new Date(to);
from.setUTCMonth(
from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
(period === 'month'
? 1
: period === 'halfYear'
? HALF_YEAR_MONTHS
: YEAR_MONTHS),
);
if (period === 'all') {
if (!historyFrom)
@@ -134,7 +146,10 @@ export class StockMarketService {
const collected = new Map<string, StockCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits.
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(
token,
uid,
@@ -11,25 +11,27 @@ import {
import { MoneyDto } from '../../../../../common/money.dto';
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
const MAX_INSTRUMENT_QUERY_LENGTH = 80;
export class StockSearchQueryDto {
@ApiProperty({ minLength: 2, maxLength: 80 })
@ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MinLength(2)
@MaxLength(80)
@MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
declare query: string;
}
export class StockTickerParamsDto {
@ApiProperty({
minLength: 1,
maxLength: 80,
maxLength: MAX_INSTRUMENT_QUERY_LENGTH,
pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$',
})
@IsString()
@MinLength(1)
@MaxLength(80)
@MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
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 };
const moscow = 'Europe/Moscow';
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 } {
const from = dateParts(range.from);
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 {
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,
};
}
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import {
BadRequestException,
Injectable,
@@ -15,6 +16,7 @@ import { AccountReferenceDto } from '../../account-reference.dto';
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
import {
BrokerageOperationDto,
DEFAULT_OPERATIONS_PAGE_SIZE,
BrokerageOperationsPageDto,
BrokerageOperationsQueryDto,
} from '../presentation/brokerage-operation.dto';
@@ -23,6 +25,8 @@ import {
operationCategory,
} from '../infrastructure/brokerage-operation.mapper';
const CONTINUATION_TTL_MS = 300_000;
type OperationStream = {
account: AccountReferenceDto;
pending: BrokerageOperationDto[];
@@ -54,7 +58,7 @@ export class BrokerageOperationsService {
AllOperationsContinuation
>({
max: 100,
ttl: 5 * 60_000,
ttl: CONTINUATION_TTL_MS,
ttlAutopurge: true,
});
@@ -86,7 +90,10 @@ export class BrokerageOperationsService {
query.instrumentUid && !query.from
? {
...range,
from: new Date(Number(account.openedDate?.seconds ?? 0) * 1000),
from: new Date(
Number(account.openedDate?.seconds ?? 0) *
MILLISECONDS_PER_SECOND,
),
}
: range,
),
@@ -108,7 +115,7 @@ export class BrokerageOperationsService {
from: query.from ?? null,
to: query.to ?? null,
categories: [...new Set(query.categories ?? [])].sort(),
limit: query.limit ?? 100,
limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE,
});
const continuation = query.cursor
? this.continuations.get(query.cursor)
@@ -178,7 +185,7 @@ export class BrokerageOperationsService {
): Promise<BrokerageOperationsPageDto> {
const items: BrokerageOperationDto[] = [];
// 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) {
while (!stream.pending.length && !stream.finished) {
const page = await this.page(
@@ -324,7 +331,9 @@ export class BrokerageOperationsService {
accountId,
async ({ token, generation, account }) => {
const accountOpenedAt = account.openedDate?.seconds
? new Date(Number(account.openedDate.seconds) * 1000)
? new Date(
Number(account.openedDate.seconds) * MILLISECONDS_PER_SECOND,
)
: undefined;
const selected =
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges;
@@ -395,7 +404,7 @@ export class BrokerageOperationsService {
from: seconds(range.from),
to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')),
cursor: query.cursor,
limit: query.limit ?? 100,
limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE,
state: 'OPERATION_STATE_EXECUTED',
...(query.instrumentUid ? { instrumentId: query.instrumentUid } : {}),
});
@@ -480,7 +489,7 @@ function cacheKey(
categories,
instrumentUid: query.instrumentUid ?? 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) {
return {
seconds: Math.floor(value.valueOf() / 1000).toString(),
seconds: Math.floor(value.valueOf() / MILLISECONDS_PER_SECOND).toString(),
...(endOfSecond ? { nanos: 999_999_999 } : {}),
};
}
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { formatDecimal } from '../../../../common/decimal';
import {
TbankMoney,
@@ -84,5 +85,7 @@ function money(value: TbankMoney | undefined) {
function timestamp(
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';
const MAX_CURSOR_LENGTH = 4096;
const MAX_OPERATIONS_PAGE_SIZE = 1000;
export const DEFAULT_OPERATIONS_PAGE_SIZE = 100;
export const operationCategories = [
'trade',
'income',
@@ -58,15 +62,19 @@ export class BrokerageOperationsQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(4096)
@MaxLength(MAX_CURSOR_LENGTH)
cursor?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 })
@ApiPropertyOptional({
minimum: 1,
maximum: MAX_OPERATIONS_PAGE_SIZE,
default: DEFAULT_OPERATIONS_PAGE_SIZE,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
@Max(MAX_OPERATIONS_PAGE_SIZE)
limit?: number;
}
@@ -6,6 +6,9 @@ import {
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;
// Build from positions and cash, never from totals (which already include cash).
@@ -59,10 +62,10 @@ export function portfolioStructure(
value:
(value.value *
10n ** BigInt(total.scale - value.scale) *
100_000_000n +
PERCENT_SCALED_FACTOR +
total.value / 2n) /
total.value,
scale: 6,
scale: SHARE_PERCENT_DECIMAL_PLACES,
})
: null,
}));
@@ -4,6 +4,7 @@ import {
Delete,
Get,
HttpCode,
HttpStatus,
Put,
Req,
UseGuards,
@@ -47,7 +48,7 @@ export class TbankConnectionController {
}
@Delete()
@HttpCode(204)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse()
delete(@Req() request: Request & { user: AccessPayload }): Promise<void> {
return this.connection.delete(request.user.sub);
@@ -1,11 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
const MAX_TOKEN_LENGTH = 4096;
export class PutTbankConnectionDto {
@ApiProperty({ writeOnly: true })
@IsString()
@IsNotEmpty()
@MaxLength(4096)
@MaxLength(MAX_TOKEN_LENGTH)
declare token: string;
}
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../common/time';
import { BadRequestException, Injectable } from '@nestjs/common';
import {
add,
@@ -25,12 +26,14 @@ import {
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
const DEFAULT_EVENT_WINDOW_DAYS = 30;
type Position = NonNullable<TbankPortfolio['positions']>[number];
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
const positionsBatchSize = 5;
const dividendCacheTtlMs = 15 * 60_000;
const instrumentCacheTtlMs = 24 * 60 * 60_000;
const dividendCacheTtlMs = 900_000;
const instrumentCacheTtlMs = 86_400_000;
@Injectable()
export class UpcomingEventsService {
@@ -242,7 +245,9 @@ export class UpcomingEventsService {
return {
...metadata,
type: 'dividend',
date: new Date(Number(event.recordDate?.seconds) * 1000)
date: new Date(
Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND,
)
.toISOString()
.slice(0, 10),
dateKind: 'registry',
@@ -287,7 +292,7 @@ function supported(position: Position): boolean {
function dateWindow(query: UpcomingEventsQueryDto) {
const from = query.from ?? new Date().toISOString().slice(0, 10);
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);
if (from > to)
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });