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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user