refactor: centralize domain values and enforce numeric lint
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const MILLISECONDS_PER_SECOND = 1_000;
|
||||
export const MILLISECONDS_PER_DAY = 86_400_000;
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user