refactor: replace ignored numeric literals with constants
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
||||
import {
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
CreateBankOperationDto,
|
||||
} from '../presentation/bank-card.dto';
|
||||
|
||||
const NEXT_MONTH_OFFSET = 1;
|
||||
const PREVIOUS_MONTH_LAST_DAY = 0;
|
||||
|
||||
export const BankErrorCode = {
|
||||
InvalidInput: 'BANK_INVALID_INPUT',
|
||||
VersionConflict: 'BANK_VERSION_CONFLICT',
|
||||
@@ -45,8 +49,8 @@ export function periodBounds(query: BankPeriodQueryDto) {
|
||||
const from = `${query.month}-01`;
|
||||
if (!isCalendarDate(from)) return fail();
|
||||
const end = new Date(`${from}T00:00:00Z`);
|
||||
end.setUTCMonth(end.getUTCMonth() + 1, 0);
|
||||
return { from, to: end.toISOString().slice(0, 10) };
|
||||
end.setUTCMonth(end.getUTCMonth() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY);
|
||||
return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) };
|
||||
}
|
||||
if (
|
||||
query.month ||
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
fingerprint,
|
||||
missing,
|
||||
} from './bank-card-input';
|
||||
|
||||
const EXPECTED_UPDATED_CARDS = 1;
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
@Injectable()
|
||||
export class BankCardsService {
|
||||
@@ -149,7 +152,7 @@ export class BankCardsService {
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (changed.count !== 1) conflict();
|
||||
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
|
||||
return this.cardView(tx, await this.repository.card(tx, userId, id));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
Prisma,
|
||||
} from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import {
|
||||
BankOperationDto,
|
||||
@@ -26,6 +27,9 @@ import {
|
||||
operationInput,
|
||||
periodBounds,
|
||||
} from './bank-card-input';
|
||||
|
||||
const EXPECTED_UPDATED_ROWS = 1;
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
@Injectable()
|
||||
export class BankOperationsService {
|
||||
@@ -77,7 +81,7 @@ export class BankOperationsService {
|
||||
row.kind === BankOperationKind.Expense || source ?
|
||||
rubMoney(
|
||||
(source?.amountKopecks ?? row.amountKopecks) -
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
|
||||
) :
|
||||
null,
|
||||
date: row.date,
|
||||
@@ -170,7 +174,7 @@ export class BankOperationsService {
|
||||
},
|
||||
data: { version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
).count !== EXPECTED_UPDATED_ROWS
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
@@ -184,7 +188,7 @@ export class BankOperationsService {
|
||||
});
|
||||
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
||||
if (
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
|
||||
input.amountKopecks >
|
||||
source.amountKopecks
|
||||
) {
|
||||
@@ -199,7 +203,7 @@ export class BankOperationsService {
|
||||
refunds.length &&
|
||||
(input.cardId !== old.cardId ||
|
||||
refunds.some((row) => row.date < input.date) ||
|
||||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
|
||||
refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) >
|
||||
input.amountKopecks)
|
||||
) {
|
||||
conflict(BankErrorCode.DependentRefunds);
|
||||
@@ -268,7 +272,7 @@ export class BankOperationsService {
|
||||
where: { id, userId, version: dto.version, deletedAt: null },
|
||||
data: { ...input, version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
).count !== EXPECTED_UPDATED_ROWS
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
@@ -308,7 +312,7 @@ export class BankOperationsService {
|
||||
where: { id: source.id, userId, version: source.version },
|
||||
data: { version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
).count !== EXPECTED_UPDATED_ROWS
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
@@ -319,7 +323,7 @@ export class BankOperationsService {
|
||||
where: { id, userId, version, deletedAt: null },
|
||||
data: { deletedAt: new Date(), version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
).count !== EXPECTED_UPDATED_ROWS
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
@@ -338,7 +342,7 @@ export class BankOperationsService {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.repository.card(tx, userId, id);
|
||||
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
||||
page = query.page ?? 1;
|
||||
page = query.page ?? FIRST_PAGE;
|
||||
const where: Prisma.BankCardOperationWhereInput = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
@@ -351,7 +355,7 @@ export class BankOperationsService {
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
where,
|
||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * limit,
|
||||
skip: (page - FIRST_PAGE) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,10 @@ import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import { conflict, missing, name } from './bank-card-input';
|
||||
|
||||
const EXPECTED_UPDATED_CATEGORIES = 1;
|
||||
const VERSION_INCREMENT = 1;
|
||||
|
||||
@Injectable()
|
||||
export class ExpenseCategoriesService {
|
||||
constructor(
|
||||
@@ -62,7 +66,7 @@ export class ExpenseCategoriesService {
|
||||
version: { increment: 1 },
|
||||
},
|
||||
})
|
||||
).count !== 1
|
||||
).count !== EXPECTED_UPDATED_CATEGORIES
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
@@ -70,7 +74,7 @@ export class ExpenseCategoriesService {
|
||||
id,
|
||||
name: label,
|
||||
isDefault: row.seedKey === 'other',
|
||||
version: version + 1,
|
||||
version: version + VERSION_INCREMENT,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
export const FIRST_PAGE = 1;
|
||||
|
||||
export const MIN_ENTITY_VERSION = 1;
|
||||
|
||||
export const MIN_CATEGORY_NAME_LENGTH = 1;
|
||||
|
||||
export const MIN_CARD_NAME_LENGTH = 1;
|
||||
export const MAX_CARD_NAME_LENGTH = 100;
|
||||
export const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
||||
import { BankOperationKind } from './bank-operation-kind';
|
||||
import { ZERO_KOPECKS } from './rub-money';
|
||||
|
||||
export function moscowToday(now = new Date()): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
@@ -10,11 +12,11 @@ export function moscowToday(now = new Date()): string {
|
||||
}
|
||||
|
||||
export function isCalendarDate(value: string): boolean {
|
||||
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
if (value.length !== ISO_DATE_LENGTH || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
if (value.startsWith('0000')) return false;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return (
|
||||
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
!Number.isNaN(date.getTime()) && date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === value
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,8 +65,8 @@ export function bankSummary(
|
||||
kind: string;
|
||||
}>,
|
||||
) {
|
||||
let expenses = 0n;
|
||||
let refunds = 0n;
|
||||
let expenses = ZERO_KOPECKS;
|
||||
let refunds = ZERO_KOPECKS;
|
||||
const categories = new Map<
|
||||
string,
|
||||
{ categoryId: string; expenses: bigint; net: bigint; refunds: bigint }
|
||||
@@ -81,19 +83,19 @@ export function bankSummary(
|
||||
const expense =
|
||||
operation.kind === BankOperationKind.Expense ?
|
||||
operation.amountKopecks :
|
||||
0n;
|
||||
ZERO_KOPECKS;
|
||||
const refund =
|
||||
operation.kind === BankOperationKind.Refund ?
|
||||
operation.amountKopecks :
|
||||
0n;
|
||||
ZERO_KOPECKS;
|
||||
expenses += expense;
|
||||
refunds += refund;
|
||||
if (operation.categoryId !== null) {
|
||||
const category = categories.get(operation.categoryId) ?? {
|
||||
categoryId: operation.categoryId,
|
||||
expenses: 0n,
|
||||
refunds: 0n,
|
||||
net: 0n,
|
||||
expenses: ZERO_KOPECKS,
|
||||
refunds: ZERO_KOPECKS,
|
||||
net: ZERO_KOPECKS,
|
||||
};
|
||||
category.expenses += expense;
|
||||
category.refunds += refund;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
|
||||
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||
|
||||
export const ZERO_KOPECKS = 0n;
|
||||
|
||||
const RUB_DECIMAL_PLACES = 2;
|
||||
|
||||
const MIN_KOPECKS = -9_223_372_036_854_775_808n;
|
||||
const MAX_KOPECKS = 9_223_372_036_854_775_807n;
|
||||
@@ -14,14 +18,14 @@ export function parseRubMoney(money: Money, positive = false): bigint {
|
||||
throw new TypeError('Expected RUB money with a decimal string');
|
||||
}
|
||||
const amount = decimalFromString(money.value);
|
||||
if (amount.scale > 2) {
|
||||
if (amount.scale > RUB_DECIMAL_PLACES) {
|
||||
throw new RangeError('RUB money supports at most two decimal places');
|
||||
}
|
||||
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
|
||||
const kopecks = amount.value * DECIMAL_RADIX ** BigInt(RUB_DECIMAL_PLACES - amount.scale);
|
||||
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) {
|
||||
throw new RangeError('RUB money is outside the signed 64-bit range');
|
||||
}
|
||||
if (positive && kopecks <= 0n) {
|
||||
if (positive && kopecks <= ZERO_KOPECKS) {
|
||||
throw new RangeError('Operation amount must be positive');
|
||||
}
|
||||
return kopecks;
|
||||
@@ -30,6 +34,6 @@ export function parseRubMoney(money: Money, positive = false): bigint {
|
||||
export function rubMoney(kopecks: bigint): Money {
|
||||
return {
|
||||
currency: 'RUB',
|
||||
value: formatDecimal({ value: kopecks, scale: 2 }),
|
||||
value: formatDecimal({ value: kopecks, scale: RUB_DECIMAL_PLACES }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
missing,
|
||||
} from '../application/bank-card-input';
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
|
||||
const INITIAL_RETRY_COUNT = 0;
|
||||
const LAST_SEED_OFFSET = 1;
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
||||
const TRANSACTION_TIMEOUT_MS = 15_000;
|
||||
@@ -28,7 +32,7 @@ const seeds = [
|
||||
export class BankCardsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
|
||||
try {
|
||||
return await this.prisma.$transaction(run, {
|
||||
maxWait: TRANSACTION_MAX_WAIT_MS,
|
||||
@@ -69,7 +73,7 @@ export class BankCardsRepository {
|
||||
|
||||
async seed(tx: Tx, userId: string) {
|
||||
for (const [index, label] of seeds.entries()) {
|
||||
const seedKey = index === seeds.length - 1 ? 'other' : String(index);
|
||||
const seedKey = index === seeds.length - LAST_SEED_OFFSET ? 'other' : String(index);
|
||||
if (
|
||||
!(await tx.expenseCategory.findUnique({
|
||||
where: { userId_seedKey: { userId, seedKey } },
|
||||
|
||||
@@ -25,12 +25,18 @@ import {
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import {
|
||||
FIRST_PAGE,
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
MAX_CATEGORY_NAME_LENGTH,
|
||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||
MIN_CARD_NAME_LENGTH,
|
||||
MIN_CATEGORY_NAME_LENGTH,
|
||||
MIN_ENTITY_VERSION,
|
||||
} from '../domain/bank-card-limits';
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
|
||||
const MIN_PAGE_SIZE = 1;
|
||||
|
||||
export const DEFAULT_BANK_PAGE_SIZE = 50;
|
||||
const MAX_BANK_PAGE_SIZE = 100;
|
||||
|
||||
@@ -48,10 +54,10 @@ export class RubMoneyInputDto {
|
||||
}
|
||||
export class CreateBankCardDto {
|
||||
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
||||
@ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH })
|
||||
@ApiProperty({ minLength: MIN_CARD_NAME_LENGTH, maxLength: MAX_CARD_NAME_LENGTH })
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MinLength(MIN_CARD_NAME_LENGTH)
|
||||
@MaxLength(MAX_CARD_NAME_LENGTH)
|
||||
declare name: string;
|
||||
|
||||
@@ -91,22 +97,22 @@ export class UpdateBankCardDto extends PartialType(
|
||||
OmitType(CreateBankCardDto, ['id'] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {
|
||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
||||
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
|
||||
@ApiPropertyOptional()
|
||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||
@IsBoolean()
|
||||
archived?: boolean;
|
||||
}
|
||||
export class CategoryNameDto {
|
||||
@ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH })
|
||||
@ApiProperty({ minLength: MIN_CATEGORY_NAME_LENGTH, maxLength: MAX_CATEGORY_NAME_LENGTH })
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MinLength(MIN_CATEGORY_NAME_LENGTH)
|
||||
@MaxLength(MAX_CATEGORY_NAME_LENGTH)
|
||||
declare name: string;
|
||||
}
|
||||
export class UpdateExpenseCategoryDto extends CategoryNameDto {
|
||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
||||
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
|
||||
}
|
||||
export class CreateBankOperationDto {
|
||||
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
|
||||
@@ -151,7 +157,7 @@ export class UpdateBankOperationDto extends PartialType(
|
||||
OmitType(CreateBankOperationDto, ['id'] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {
|
||||
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
|
||||
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
|
||||
}
|
||||
export class BankCardsQueryDto {
|
||||
@ApiPropertyOptional({ enum: ['true', 'false'] })
|
||||
@@ -160,22 +166,22 @@ export class BankCardsQueryDto {
|
||||
includeArchived?: string;
|
||||
}
|
||||
export class BankPageQueryDto {
|
||||
@ApiPropertyOptional({ minimum: 1, default: 1 })
|
||||
@ApiPropertyOptional({ minimum: FIRST_PAGE, default: FIRST_PAGE })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Min(FIRST_PAGE)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
minimum: MIN_PAGE_SIZE,
|
||||
maximum: MAX_BANK_PAGE_SIZE,
|
||||
default: DEFAULT_BANK_PAGE_SIZE,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Min(MIN_PAGE_SIZE)
|
||||
@Max(MAX_BANK_PAGE_SIZE)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -215,10 +221,10 @@ export class BankSummaryQueryDto extends BankPeriodQueryDto {
|
||||
cardId?: string;
|
||||
}
|
||||
export class BankVersionQueryDto {
|
||||
@ApiProperty({ minimum: 1 })
|
||||
@ApiProperty({ minimum: MIN_ENTITY_VERSION })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Min(MIN_ENTITY_VERSION)
|
||||
declare version: number;
|
||||
}
|
||||
export class ExpenseCategoryDto {
|
||||
|
||||
Reference in New Issue
Block a user