refactor: centralize domain values and enforce numeric lint
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user