chore: strengthen backend eslint profile

This commit is contained in:
2026-09-28 11:04:18 +03:00
parent ca5dbcb382
commit 9b3558f96c
179 changed files with 4485 additions and 2716 deletions
@@ -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({