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();