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,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,