chore: strengthen backend eslint profile
This commit is contained in:
@@ -1,21 +1,22 @@
|
||||
import {
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||
} from '../domain/bank-card-limits';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||
} from '../domain/bank-card-limits';
|
||||
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
|
||||
import { parseRubMoney } from '../domain/rub-money';
|
||||
import {
|
||||
bankPaymentSystems,
|
||||
BankPeriodQueryDto,
|
||||
CreateBankCardDto,
|
||||
CreateBankOperationDto,
|
||||
} from '../presentation/bank-card.dto';
|
||||
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
|
||||
import { parseRubMoney } from '../domain/rub-money';
|
||||
|
||||
export const BankErrorCode = {
|
||||
InvalidInput: 'BANK_INVALID_INPUT',
|
||||
@@ -54,8 +55,9 @@ export function periodBounds(query: BankPeriodQueryDto) {
|
||||
!isCalendarDate(query.from) ||
|
||||
!isCalendarDate(query.to) ||
|
||||
query.from > query.to
|
||||
)
|
||||
) {
|
||||
return fail();
|
||||
}
|
||||
return { from: query.from, to: query.to };
|
||||
}
|
||||
export function conflict(
|
||||
@@ -93,8 +95,9 @@ export function currentDate(value: string) {
|
||||
if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
|
||||
}
|
||||
export function name(value: string, max: number) {
|
||||
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
|
||||
if (typeof value !== 'string' || !value.trim() || value.trim().length > max) {
|
||||
fail();
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
export function cardInput(dto: CreateBankCardDto) {
|
||||
@@ -104,8 +107,9 @@ export function cardInput(dto: CreateBankCardDto) {
|
||||
if (
|
||||
dto.paymentSystem != null &&
|
||||
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
|
||||
)
|
||||
) {
|
||||
fail();
|
||||
}
|
||||
return {
|
||||
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
|
||||
name: name(dto.name, MAX_CARD_NAME_LENGTH),
|
||||
@@ -120,8 +124,9 @@ export function operationInput(dto: CreateBankOperationDto) {
|
||||
dto.description != null &&
|
||||
(typeof dto.description !== 'string' ||
|
||||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
|
||||
)
|
||||
) {
|
||||
fail();
|
||||
}
|
||||
return {
|
||||
kind: dto.kind,
|
||||
cardId: dto.cardId,
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { bankBalance } from '../domain/bank-card.calculations';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import {
|
||||
BankCardDto,
|
||||
CreateBankCardDto,
|
||||
UpdateBankCardDto,
|
||||
} from '../presentation/bank-card.dto';
|
||||
import { bankBalance } from '../domain/bank-card.calculations';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
|
||||
import {
|
||||
cardInput,
|
||||
BankErrorCode,
|
||||
cardInput,
|
||||
conflict,
|
||||
currentDate,
|
||||
fail,
|
||||
fingerprint,
|
||||
missing,
|
||||
} from './bank-card-input';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
type Tx = Prisma.TransactionClient;
|
||||
@Injectable()
|
||||
export class BankCardsService {
|
||||
@@ -26,6 +26,7 @@ export class BankCardsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: BankCardsRepository,
|
||||
) {}
|
||||
|
||||
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
@@ -47,6 +48,7 @@ export class BankCardsService {
|
||||
archivedAt: card.archivedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
list(userId: string, includeArchived = false) {
|
||||
return this.prisma.$transaction(async (tx) =>
|
||||
Promise.all(
|
||||
@@ -59,11 +61,13 @@ export class BankCardsService {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get(userId: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) =>
|
||||
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
||||
);
|
||||
}
|
||||
|
||||
createCard(userId: string, dto: CreateBankCardDto) {
|
||||
const input = cardInput(dto),
|
||||
hash = fingerprint(input);
|
||||
@@ -71,8 +75,10 @@ export class BankCardsService {
|
||||
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
||||
if (existing) {
|
||||
if (existing.userId !== userId) missing();
|
||||
if (existing.createFingerprint !== hash)
|
||||
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
||||
if (existing.createFingerprint !== hash) {
|
||||
conflict(BankErrorCode.IntentConflict);
|
||||
}
|
||||
return this.cardView(tx, existing);
|
||||
}
|
||||
currentDate(input.openingDate);
|
||||
@@ -85,6 +91,7 @@ export class BankCardsService {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
|
||||
return this.repository.write(async (tx) => {
|
||||
const previous = await this.repository.card(tx, userId, id);
|
||||
@@ -93,17 +100,17 @@ export class BankCardsService {
|
||||
id,
|
||||
name: dto.name ?? previous.name,
|
||||
bankName:
|
||||
dto.bankName === undefined
|
||||
? (previous.bankName ?? undefined)
|
||||
: (dto.bankName ?? undefined),
|
||||
dto.bankName === undefined ?
|
||||
(previous.bankName ?? undefined) :
|
||||
(dto.bankName ?? undefined),
|
||||
lastFour:
|
||||
dto.lastFour === undefined
|
||||
? (previous.lastFour ?? undefined)
|
||||
: (dto.lastFour ?? undefined),
|
||||
dto.lastFour === undefined ?
|
||||
(previous.lastFour ?? undefined) :
|
||||
(dto.lastFour ?? undefined),
|
||||
paymentSystem:
|
||||
dto.paymentSystem === undefined
|
||||
? previous.paymentSystem
|
||||
: dto.paymentSystem,
|
||||
dto.paymentSystem === undefined ?
|
||||
previous.paymentSystem :
|
||||
dto.paymentSystem,
|
||||
openingBalance:
|
||||
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
|
||||
openingDate: dto.openingDate ?? previous.openingDate,
|
||||
@@ -111,8 +118,9 @@ export class BankCardsService {
|
||||
const financial =
|
||||
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
|
||||
input.openingDate !== previous.openingDate;
|
||||
if (financial && previous.archivedAt)
|
||||
if (financial && previous.archivedAt) {
|
||||
conflict(BankErrorCode.CardArchived);
|
||||
}
|
||||
currentDate(input.openingDate);
|
||||
if (
|
||||
await tx.bankCardOperation.findFirst({
|
||||
@@ -123,20 +131,21 @@ export class BankCardsService {
|
||||
OR: [{ cardId: id }, { targetCardId: id }],
|
||||
},
|
||||
})
|
||||
)
|
||||
) {
|
||||
fail(BankErrorCode.OpeningDateConflict);
|
||||
}
|
||||
const changed = await tx.bankCard.updateMany({
|
||||
where: { id, userId, version: dto.version },
|
||||
data: {
|
||||
...input,
|
||||
paymentSystem: input.paymentSystem ?? null,
|
||||
version: { increment: 1 },
|
||||
...(dto.archived === undefined
|
||||
? {}
|
||||
: {
|
||||
archivedAt: dto.archived
|
||||
? (previous.archivedAt ?? new Date())
|
||||
: null,
|
||||
...(dto.archived === undefined ?
|
||||
{} :
|
||||
{
|
||||
archivedAt: dto.archived ?
|
||||
(previous.archivedAt ?? new Date()) :
|
||||
null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
import { GoneException, Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
BankCardOperation,
|
||||
Prisma,
|
||||
} from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import {
|
||||
BankOperationDto,
|
||||
DEFAULT_BANK_PAGE_SIZE,
|
||||
BankOperationsQueryDto,
|
||||
BankPageQueryDto,
|
||||
CreateBankOperationDto,
|
||||
DEFAULT_BANK_PAGE_SIZE,
|
||||
UpdateBankOperationDto,
|
||||
} from '../presentation/bank-card.dto';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
|
||||
import {
|
||||
BankErrorCode,
|
||||
conflict,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
operationInput,
|
||||
periodBounds,
|
||||
} from './bank-card-input';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
type Tx = Prisma.TransactionClient;
|
||||
@Injectable()
|
||||
export class BankOperationsService {
|
||||
@@ -33,34 +33,35 @@ export class BankOperationsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: BankCardsRepository,
|
||||
) {}
|
||||
|
||||
private async operationView(
|
||||
tx: Tx,
|
||||
row: BankCardOperation,
|
||||
): Promise<BankOperationDto> {
|
||||
const card = await this.repository.card(tx, row.userId, row.cardId);
|
||||
const target = row.targetCardId
|
||||
? await this.repository.card(tx, row.userId, row.targetCardId)
|
||||
: null;
|
||||
const source = row.originalExpenseId
|
||||
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
|
||||
: null;
|
||||
const target = row.targetCardId ?
|
||||
await this.repository.card(tx, row.userId, row.targetCardId) :
|
||||
null;
|
||||
const source = row.originalExpenseId ?
|
||||
await this.repository.operation(tx, row.userId, row.originalExpenseId) :
|
||||
null;
|
||||
const categoryId = source?.categoryId ?? row.categoryId;
|
||||
const category = categoryId
|
||||
? await tx.expenseCategory.findFirst({
|
||||
const category = categoryId ?
|
||||
await tx.expenseCategory.findFirst({
|
||||
where: { id: categoryId, userId: row.userId },
|
||||
})
|
||||
: null;
|
||||
}) :
|
||||
null;
|
||||
const refunds =
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? await tx.bankCardOperation.findMany({
|
||||
row.kind === BankOperationKind.Expense || source ?
|
||||
await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId: row.userId,
|
||||
originalExpenseId: source?.id ?? row.id,
|
||||
deletedAt: null,
|
||||
...(source ? { id: { not: row.id } } : {}),
|
||||
},
|
||||
})
|
||||
: [];
|
||||
}) :
|
||||
[];
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as BankOperationDto['kind'],
|
||||
@@ -73,18 +74,19 @@ export class BankOperationsService {
|
||||
originalExpenseId: row.originalExpenseId,
|
||||
amount: rubMoney(row.amountKopecks),
|
||||
availableRefund:
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? rubMoney(
|
||||
row.kind === BankOperationKind.Expense || source ?
|
||||
rubMoney(
|
||||
(source?.amountKopecks ?? row.amountKopecks) -
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
|
||||
)
|
||||
: null,
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
|
||||
) :
|
||||
null,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async validateOperation(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
@@ -102,23 +104,26 @@ export class BankOperationsService {
|
||||
if (
|
||||
(id === input.cardId || id === input.targetCardId) &&
|
||||
input.date < card.openingDate
|
||||
)
|
||||
) {
|
||||
fail(BankErrorCode.InvalidDate);
|
||||
}
|
||||
}
|
||||
if (
|
||||
old &&
|
||||
(old.kind !== input.kind ||
|
||||
old.originalExpenseId !== input.originalExpenseId)
|
||||
)
|
||||
) {
|
||||
fail(BankErrorCode.ImmutableOperation);
|
||||
}
|
||||
if (input.kind === BankOperationKind.Transfer) {
|
||||
if (
|
||||
!input.targetCardId ||
|
||||
input.targetCardId === input.cardId ||
|
||||
input.categoryId ||
|
||||
input.originalExpenseId
|
||||
)
|
||||
) {
|
||||
fail();
|
||||
}
|
||||
} else if (input.targetCardId) fail();
|
||||
if (input.kind === BankOperationKind.Expense) {
|
||||
if (input.originalExpenseId) fail();
|
||||
@@ -133,11 +138,13 @@ export class BankOperationsService {
|
||||
!(await tx.expenseCategory.findFirst({
|
||||
where: { userId, id: input.categoryId },
|
||||
}))
|
||||
)
|
||||
) {
|
||||
missing();
|
||||
}
|
||||
} else if (input.categoryId) fail();
|
||||
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId)
|
||||
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
|
||||
fail();
|
||||
}
|
||||
if (input.kind === BankOperationKind.Refund) {
|
||||
if (!input.originalExpenseId) fail();
|
||||
const source = await this.repository.operation(
|
||||
@@ -149,8 +156,9 @@ export class BankOperationsService {
|
||||
source.deletedAt ||
|
||||
source.kind !== BankOperationKind.Expense ||
|
||||
source.cardId !== input.cardId
|
||||
)
|
||||
) {
|
||||
fail(BankErrorCode.InvalidRefund);
|
||||
}
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
@@ -163,8 +171,9 @@ export class BankOperationsService {
|
||||
data: { version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
const refunds = await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
@@ -176,10 +185,11 @@ export class BankOperationsService {
|
||||
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
||||
if (
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
|
||||
input.amountKopecks >
|
||||
input.amountKopecks >
|
||||
source.amountKopecks
|
||||
)
|
||||
) {
|
||||
conflict(BankErrorCode.RefundLimit);
|
||||
}
|
||||
}
|
||||
if (old?.kind === BankOperationKind.Expense) {
|
||||
const refunds = await tx.bankCardOperation.findMany({
|
||||
@@ -190,11 +200,13 @@ export class BankOperationsService {
|
||||
(input.cardId !== old.cardId ||
|
||||
refunds.some((row) => row.date < input.date) ||
|
||||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
|
||||
input.amountKopecks)
|
||||
)
|
||||
input.amountKopecks)
|
||||
) {
|
||||
conflict(BankErrorCode.DependentRefunds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createOperation(userId: string, dto: CreateBankOperationDto) {
|
||||
const normalized = operationInput(dto),
|
||||
hash = fingerprint(normalized);
|
||||
@@ -204,10 +216,13 @@ export class BankOperationsService {
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.userId !== userId) missing();
|
||||
if (existing.createFingerprint !== hash)
|
||||
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
||||
if (existing.createFingerprint !== hash) {
|
||||
conflict(BankErrorCode.IntentConflict);
|
||||
if (existing.deletedAt)
|
||||
}
|
||||
if (existing.deletedAt) {
|
||||
throw new GoneException({ code: BankErrorCode.OperationDeleted });
|
||||
}
|
||||
return this.operationView(tx, existing);
|
||||
}
|
||||
const input = { ...normalized };
|
||||
@@ -220,6 +235,7 @@ export class BankOperationsService {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getOperation(userId: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const row = await this.repository.operation(tx, userId, id);
|
||||
@@ -227,6 +243,7 @@ export class BankOperationsService {
|
||||
return this.operationView(tx, row);
|
||||
});
|
||||
}
|
||||
|
||||
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
|
||||
return this.repository.write(async (tx) => {
|
||||
const old = await this.repository.operation(tx, userId, id);
|
||||
@@ -252,29 +269,33 @@ export class BankOperationsService {
|
||||
data: { ...input, version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
return this.operationView(
|
||||
tx,
|
||||
await this.repository.operation(tx, userId, id),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
deleteOperation(userId: string, id: string, version: number) {
|
||||
return this.repository.write(async (tx) => {
|
||||
const row = await this.repository.operation(tx, userId, id);
|
||||
if (row.deletedAt) return;
|
||||
if (row.version !== version) conflict();
|
||||
await this.repository.card(tx, userId, row.cardId, true);
|
||||
if (row.targetCardId)
|
||||
if (row.targetCardId) {
|
||||
await this.repository.card(tx, userId, row.targetCardId, true);
|
||||
}
|
||||
if (
|
||||
row.kind === BankOperationKind.Expense &&
|
||||
(await tx.bankCardOperation.findFirst({
|
||||
where: { userId, originalExpenseId: id, deletedAt: null },
|
||||
}))
|
||||
)
|
||||
) {
|
||||
conflict(BankErrorCode.DependentRefunds);
|
||||
}
|
||||
if (row.originalExpenseId) {
|
||||
const source = await this.repository.operation(
|
||||
tx,
|
||||
@@ -288,8 +309,9 @@ export class BankOperationsService {
|
||||
data: { version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
}
|
||||
if (
|
||||
(
|
||||
@@ -298,30 +320,32 @@ export class BankOperationsService {
|
||||
data: { deletedAt: new Date(), version: { increment: 1 } },
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
history(
|
||||
userId: string,
|
||||
id: string,
|
||||
query: BankOperationsQueryDto | BankPageQueryDto,
|
||||
expenses = false,
|
||||
) {
|
||||
const period = expenses
|
||||
? undefined
|
||||
: periodBounds(query as BankOperationsQueryDto);
|
||||
const period = expenses ?
|
||||
undefined :
|
||||
periodBounds(query as BankOperationsQueryDto);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.repository.card(tx, userId, id);
|
||||
const page = query.page ?? 1,
|
||||
limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE;
|
||||
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
||||
page = query.page ?? 1;
|
||||
const where: Prisma.BankCardOperationWhereInput = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
OR: [{ cardId: id }, { targetCardId: id }],
|
||||
...(period
|
||||
? { date: { gte: period.from, lte: period.to } }
|
||||
: { kind: BankOperationKind.Expense }),
|
||||
...(period ?
|
||||
{ date: { gte: period.from, lte: period.to } } :
|
||||
{ kind: BankOperationKind.Expense }),
|
||||
};
|
||||
const total = await tx.bankCardOperation.count({ where });
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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';
|
||||
|
||||
import { conflict, missing, name } from './bank-card-input';
|
||||
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';
|
||||
@Injectable()
|
||||
export class ExpenseCategoriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: BankCardsRepository,
|
||||
) {}
|
||||
|
||||
async categories(userId: string) {
|
||||
return (
|
||||
await this.prisma.expenseCategory.findMany({
|
||||
@@ -24,6 +25,7 @@ export class ExpenseCategoriesService {
|
||||
version,
|
||||
}));
|
||||
}
|
||||
|
||||
createCategory(userId: string, value: string) {
|
||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||
return this.repository.write(async (tx) => {
|
||||
@@ -44,6 +46,7 @@ export class ExpenseCategoriesService {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
updateCategory(userId: string, id: string, value: string, version: number) {
|
||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||
return this.repository.write(async (tx) => {
|
||||
@@ -60,8 +63,9 @@ export class ExpenseCategoriesService {
|
||||
},
|
||||
})
|
||||
).count !== 1
|
||||
)
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name: label,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
|
||||
|
||||
import { bankSummary } from '../domain/bank-card.calculations';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
|
||||
import { periodBounds } from './bank-card-input';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
|
||||
import { periodBounds } from './bank-card-input';
|
||||
@Injectable()
|
||||
export class GetBankSummaryUseCase {
|
||||
constructor(private readonly repository: BankCardsRepository) {}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { BankCardsController } from './presentation/bank-cards.controller';
|
||||
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
|
||||
import { BankCardsService } from './application/bank-cards.service';
|
||||
import { BankOperationsService } from './application/bank-operations.service';
|
||||
import { ExpenseCategoriesService } from './application/expense-categories.service';
|
||||
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
|
||||
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
|
||||
import { BankCardsController } from './presentation/bank-cards.controller';
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BankCardsController],
|
||||
|
||||
@@ -22,10 +22,10 @@ export function bankBalance(
|
||||
cardId: string,
|
||||
opening: bigint,
|
||||
operations: Array<{
|
||||
kind: string;
|
||||
cardId: string;
|
||||
targetCardId: string | null;
|
||||
amountKopecks: bigint;
|
||||
cardId: string;
|
||||
kind: string;
|
||||
targetCardId: null | string;
|
||||
}>,
|
||||
): bigint {
|
||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||
@@ -35,19 +35,21 @@ export function bankBalance(
|
||||
if (
|
||||
operation.kind === BankOperationKind.Expense ||
|
||||
operation.kind === BankOperationKind.Transfer
|
||||
)
|
||||
) {
|
||||
balance -= operation.amountKopecks;
|
||||
else if (
|
||||
} else if (
|
||||
operation.kind === BankOperationKind.Topup ||
|
||||
operation.kind === BankOperationKind.Refund
|
||||
)
|
||||
) {
|
||||
balance += operation.amountKopecks;
|
||||
}
|
||||
}
|
||||
if (
|
||||
operation.kind === BankOperationKind.Transfer &&
|
||||
operation.targetCardId === cardId
|
||||
)
|
||||
) {
|
||||
balance += operation.amountKopecks;
|
||||
}
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
@@ -55,17 +57,17 @@ export function bankBalance(
|
||||
export function bankSummary(
|
||||
period: { from: string; to: string },
|
||||
operations: Array<{
|
||||
kind: string;
|
||||
date: string;
|
||||
amountKopecks: bigint;
|
||||
categoryId: string | null;
|
||||
categoryId: null | string;
|
||||
date: string;
|
||||
kind: string;
|
||||
}>,
|
||||
) {
|
||||
let expenses = 0n;
|
||||
let refunds = 0n;
|
||||
const categories = new Map<
|
||||
string,
|
||||
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint }
|
||||
{ categoryId: string; expenses: bigint; net: bigint; refunds: bigint }
|
||||
>();
|
||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||
for (const operation of operations) {
|
||||
@@ -73,16 +75,17 @@ export function bankSummary(
|
||||
if (
|
||||
operation.kind !== BankOperationKind.Expense &&
|
||||
operation.kind !== BankOperationKind.Refund
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const expense =
|
||||
operation.kind === BankOperationKind.Expense
|
||||
? operation.amountKopecks
|
||||
: 0n;
|
||||
operation.kind === BankOperationKind.Expense ?
|
||||
operation.amountKopecks :
|
||||
0n;
|
||||
const refund =
|
||||
operation.kind === BankOperationKind.Refund
|
||||
? operation.amountKopecks
|
||||
: 0n;
|
||||
operation.kind === BankOperationKind.Refund ?
|
||||
operation.amountKopecks :
|
||||
0n;
|
||||
expenses += expense;
|
||||
refunds += refund;
|
||||
if (operation.categoryId !== null) {
|
||||
|
||||
@@ -39,8 +39,9 @@ describe('RUB money', () => {
|
||||
});
|
||||
|
||||
it('requires positive operation amounts, including at the maximum', () => {
|
||||
for (const value of ['0', '-0.00', '-0.01'])
|
||||
for (const value of ['0', '-0.00', '-0.01']) {
|
||||
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
|
||||
}
|
||||
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
|
||||
expect(
|
||||
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
|
||||
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||
|
||||
const MIN_KOPECKS = -9_223_372_036_854_775_808n;
|
||||
const MAX_KOPECKS = 9_223_372_036_854_775_807n;
|
||||
|
||||
@@ -9,16 +10,20 @@ export function parseRubMoney(money: Money, positive = false): bigint {
|
||||
money?.currency !== 'RUB' ||
|
||||
typeof money.value !== 'string' ||
|
||||
money.value !== money.value.trim()
|
||||
)
|
||||
) {
|
||||
throw new TypeError('Expected RUB money with a decimal string');
|
||||
}
|
||||
const amount = decimalFromString(money.value);
|
||||
if (amount.scale > 2)
|
||||
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 < MIN_KOPECKS || kopecks > MAX_KOPECKS)
|
||||
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 <= 0n) {
|
||||
throw new RangeError('Operation amount must be positive');
|
||||
}
|
||||
return kopecks;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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 {
|
||||
BankErrorCode,
|
||||
conflict,
|
||||
missing,
|
||||
} from '../application/bank-card-input';
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
type Tx = Prisma.TransactionClient;
|
||||
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
||||
const TRANSACTION_TIMEOUT_MS = 15_000;
|
||||
@@ -36,33 +36,37 @@ export class BankCardsRepository {
|
||||
});
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof Prisma.PrismaClientKnownRequestError
|
||||
? error.code
|
||||
: '';
|
||||
error instanceof Prisma.PrismaClientKnownRequestError ?
|
||||
error.code :
|
||||
'';
|
||||
const busy =
|
||||
code === 'P2034' ||
|
||||
code === 'P2028' ||
|
||||
(error instanceof Error &&
|
||||
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT)
|
||||
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) {
|
||||
continue;
|
||||
}
|
||||
if (busy) conflict(BankErrorCode.WriteConflict);
|
||||
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(BankErrorCode.CardArchived);
|
||||
return card;
|
||||
}
|
||||
|
||||
async operation(tx: Tx, userId: string, id: string) {
|
||||
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
|
||||
if (!row) return missing();
|
||||
return row;
|
||||
}
|
||||
|
||||
async seed(tx: Tx, userId: string) {
|
||||
for (const [index, label] of seeds.entries()) {
|
||||
const seedKey = index === seeds.length - 1 ? 'other' : String(index);
|
||||
@@ -70,7 +74,7 @@ export class BankCardsRepository {
|
||||
!(await tx.expenseCategory.findUnique({
|
||||
where: { userId_seedKey: { userId, seedKey } },
|
||||
}))
|
||||
)
|
||||
) {
|
||||
await tx.expenseCategory.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
@@ -80,8 +84,10 @@ export class BankCardsRepository {
|
||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summaryData(
|
||||
userId: string,
|
||||
period: { from: string; to: string },
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
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,
|
||||
@@ -28,7 +22,14 @@ import {
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
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';
|
||||
|
||||
export const DEFAULT_BANK_PAGE_SIZE = 50;
|
||||
const MAX_BANK_PAGE_SIZE = 100;
|
||||
@@ -41,6 +42,7 @@ export class RubMoneyInputDto {
|
||||
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
|
||||
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
|
||||
@IsString()
|
||||
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
|
||||
@Matches(/^-?\d+(\.\d{1,2})?$/)
|
||||
declare value: string;
|
||||
}
|
||||
@@ -52,6 +54,7 @@ export class CreateBankCardDto {
|
||||
@MinLength(1)
|
||||
@MaxLength(MAX_CARD_NAME_LENGTH)
|
||||
declare name: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
@@ -61,20 +64,24 @@ export class CreateBankCardDto {
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@MaxLength(MAX_CARD_NAME_LENGTH)
|
||||
bankName?: string | null;
|
||||
bankName?: null | string;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
|
||||
@IsOptional()
|
||||
@Matches(/^[0-9]{4}$/)
|
||||
lastFour?: string | null;
|
||||
@Matches(/^\d{4}$/)
|
||||
lastFour?: null | string;
|
||||
|
||||
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
|
||||
@IsOptional()
|
||||
@IsIn(bankPaymentSystems)
|
||||
paymentSystem?: string | null;
|
||||
paymentSystem?: null | string;
|
||||
|
||||
@ApiProperty({ type: RubMoneyInputDto })
|
||||
@IsDefined()
|
||||
@ValidateNested()
|
||||
@Type(() => RubMoneyInputDto)
|
||||
declare openingBalance: MoneyDto;
|
||||
|
||||
@ApiProperty({ format: 'date' })
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@@ -106,28 +113,34 @@ export class CreateBankOperationDto {
|
||||
@ApiProperty({ enum: bankOperationKinds })
|
||||
@IsIn(bankOperationKinds)
|
||||
declare kind: BankOperationKind;
|
||||
|
||||
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||
@IsUUID()
|
||||
targetCardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||
@IsUUID()
|
||||
categoryId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||
@IsUUID()
|
||||
originalExpenseId?: string;
|
||||
|
||||
@ApiProperty({ type: RubMoneyInputDto })
|
||||
@IsDefined()
|
||||
@ValidateNested()
|
||||
@Type(() => RubMoneyInputDto)
|
||||
declare amount: MoneyDto;
|
||||
|
||||
@ApiProperty({ format: 'date' })
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
declare date: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
|
||||
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||
@IsString()
|
||||
@@ -153,6 +166,7 @@ export class BankPageQueryDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
maximum: MAX_BANK_PAGE_SIZE,
|
||||
@@ -173,6 +187,7 @@ export class BankPeriodQueryDto {
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||
month?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'date',
|
||||
description: 'Inclusive start date; requires to.',
|
||||
@@ -180,6 +195,7 @@ export class BankPeriodQueryDto {
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'date',
|
||||
description: 'Inclusive end date; requires from.',
|
||||
@@ -215,16 +231,20 @@ export class BankCardDto {
|
||||
@ApiProperty({ type: String }) declare id: string;
|
||||
@ApiProperty({ type: String }) declare name: string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare bankName:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare lastFour:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ enum: bankPaymentSystems, nullable: true })
|
||||
declare paymentSystem: string | null;
|
||||
declare paymentSystem: null | string;
|
||||
|
||||
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
|
||||
@ApiProperty({ type: String }) declare openingDate: string;
|
||||
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
|
||||
@ApiProperty({ type: String, nullable: true }) declare archivedAt:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: Number }) declare version: number;
|
||||
}
|
||||
export class BankOperationDto {
|
||||
@@ -233,27 +253,35 @@ export class BankOperationDto {
|
||||
@ApiProperty({ type: String }) declare cardId: string;
|
||||
@ApiProperty({ type: String }) declare cardName: string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare targetCardId:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare targetCardName:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare categoryId:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare categoryName:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare availableRefund: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String }) declare date: string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare description:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: Number }) declare version: number;
|
||||
@ApiProperty({ type: String }) declare createdAt: string;
|
||||
}
|
||||
export class BankOperationsPageDto {
|
||||
@ApiProperty({ type: BankOperationDto, isArray: true })
|
||||
declare items: BankOperationDto[];
|
||||
|
||||
@ApiProperty({ type: Number }) declare page: number;
|
||||
@ApiProperty({ type: Number }) declare total: number;
|
||||
@ApiProperty({ type: Boolean }) declare hasNext: boolean;
|
||||
@@ -269,7 +297,7 @@ export class BankSummaryDto {
|
||||
@ApiPropertyOptional({ type: String }) month?: string;
|
||||
@ApiPropertyOptional({ format: 'date' }) from?: string;
|
||||
@ApiPropertyOptional({ format: 'date' }) to?: string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare cardId: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) declare cardId: null | string;
|
||||
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
|
||||
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
|
||||
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -21,12 +23,16 @@ import {
|
||||
ApiTags,
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
ApiProblemResponses,
|
||||
ProblemDetailsDto,
|
||||
} from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||
import { BankCardsService } from '../application/bank-cards.service';
|
||||
import { BankOperationsService } from '../application/bank-operations.service';
|
||||
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
||||
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
|
||||
import {
|
||||
BankCardDto,
|
||||
BankCardsQueryDto,
|
||||
@@ -45,10 +51,6 @@ import {
|
||||
UpdateBankOperationDto,
|
||||
UpdateExpenseCategoryDto,
|
||||
} from './bank-card.dto';
|
||||
import { BankCardsService } from '../application/bank-cards.service';
|
||||
import { BankOperationsService } from '../application/bank-operations.service';
|
||||
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
||||
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
|
||||
type AuthRequest = Request & { user: AccessPayload };
|
||||
@ApiResponse({
|
||||
status: 409,
|
||||
@@ -82,26 +84,31 @@ export class BankCardsController {
|
||||
private readonly expenseCategories: ExpenseCategoriesService,
|
||||
private readonly bankSummary: GetBankSummaryUseCase,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ type: BankCardDto, isArray: true })
|
||||
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
|
||||
return this.cards.list(req.user.sub, query.includeArchived === 'true');
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiCreatedResponse({ type: BankCardDto })
|
||||
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
|
||||
return this.cards.createCard(req.user.sub, body);
|
||||
}
|
||||
|
||||
@Get('categories')
|
||||
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
|
||||
categories(@Req() req: AuthRequest) {
|
||||
return this.expenseCategories.categories(req.user.sub);
|
||||
}
|
||||
|
||||
@Post('categories')
|
||||
@ApiCreatedResponse({ type: ExpenseCategoryDto })
|
||||
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
|
||||
return this.expenseCategories.createCategory(req.user.sub, body.name);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
@ApiOkResponse({ type: ExpenseCategoryDto })
|
||||
updateCategory(
|
||||
@@ -116,11 +123,13 @@ export class BankCardsController {
|
||||
body.version,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOkResponse({ type: BankSummaryDto })
|
||||
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
|
||||
return this.bankSummary.execute(req.user.sub, query);
|
||||
}
|
||||
|
||||
@Post('operations')
|
||||
@ApiCreatedResponse({ type: BankOperationDto })
|
||||
createOperation(
|
||||
@@ -129,11 +138,13 @@ export class BankCardsController {
|
||||
) {
|
||||
return this.operations.createOperation(req.user.sub, body);
|
||||
}
|
||||
|
||||
@Get('operations/:id')
|
||||
@ApiOkResponse({ type: BankOperationDto })
|
||||
operation(@Req() req: AuthRequest, @Param('id') id: string) {
|
||||
return this.operations.getOperation(req.user.sub, id);
|
||||
}
|
||||
|
||||
@Patch('operations/:id')
|
||||
@ApiOkResponse({ type: BankOperationDto })
|
||||
updateOperation(
|
||||
@@ -143,6 +154,7 @@ export class BankCardsController {
|
||||
) {
|
||||
return this.operations.updateOperation(req.user.sub, id, body);
|
||||
}
|
||||
|
||||
@Delete('operations/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiNoContentResponse()
|
||||
@@ -153,6 +165,7 @@ export class BankCardsController {
|
||||
) {
|
||||
return this.operations.deleteOperation(req.user.sub, id, query.version);
|
||||
}
|
||||
|
||||
@Get(':id/operations')
|
||||
@ApiOkResponse({ type: BankOperationsPageDto })
|
||||
history(
|
||||
@@ -162,6 +175,7 @@ export class BankCardsController {
|
||||
) {
|
||||
return this.operations.history(req.user.sub, id, query);
|
||||
}
|
||||
|
||||
@Get(':id/expenses')
|
||||
@ApiOkResponse({ type: BankOperationsPageDto })
|
||||
expenses(
|
||||
@@ -171,11 +185,13 @@ export class BankCardsController {
|
||||
) {
|
||||
return this.operations.history(req.user.sub, id, query, true);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOkResponse({ type: BankCardDto })
|
||||
get(@Req() req: AuthRequest, @Param('id') id: string) {
|
||||
return this.cards.get(req.user.sub, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOkResponse({ type: BankCardDto })
|
||||
update(
|
||||
|
||||
Reference in New Issue
Block a user