6 Commits
Author SHA1 Message Date
ksv741 a7e98c0ec3 refactor: enforce repository boundary in application (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m41s
Backend verify / security (pull_request) Successful in 1m13s
Backend verify / verify (pull_request) Successful in 6m50s
2026-11-04 16:53:41 +03:00
ksv741 4e3e58e288 refactor: move auth persistence into repository (WealthLedger/backend#171) 2026-11-04 16:46:49 +03:00
ksv741 1ddebbdbad refactor: route bank operation queries through repository (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m39s
Backend verify / security (pull_request) Successful in 1m12s
Backend verify / verify (pull_request) Successful in 6m57s
2026-11-04 16:46:21 +03:00
ksv741 6488e5d1ad refactor: move bank card queries into repository (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m35s
Backend verify / security (pull_request) Successful in 1m10s
Backend verify / verify (pull_request) Successful in 6m39s
2026-11-04 16:34:40 +03:00
ksv741 5c7ff312ee refactor: route bank card reads through repository (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m30s
Backend verify / security (pull_request) Successful in 1m8s
Backend verify / verify (pull_request) Failing after 5m52s
2026-11-04 16:30:45 +03:00
ksv741 0f65ba7f13 refactor: move category persistence into bank repository (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m30s
Backend verify / security (pull_request) Successful in 1m14s
Backend verify / verify (pull_request) Successful in 6m49s
2026-11-04 14:52:38 +03:00
12 changed files with 417 additions and 346 deletions
+6 -6
View File
@@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records; domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
чистые расчёты portfolio/banking остаются у domain owner. чистые расчёты portfolio/banking остаются у domain owner.
PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные PrismaService владеет DB lifecycle. Application services/use cases получают
через concrete repositories; существующие связные CRUD/transaction services данные через concrete repositories; запросы и CAS живут в infrastructure
сохраняют прямые queries по [data convention](conventions/data.md). по [data convention](conventions/data.md).
Money — currency + decimal string; расчёты используют точные общие helpers. Money — currency + decimal string; расчёты используют точные общие helpers.
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
@@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio
конкретные infrastructure providers, зарегистрированные module. Infrastructure конкретные infrastructure providers, зарегистрированные module. Infrastructure
может использовать application types/input и domain rules; новые interfaces может использовать application types/input и domain rules; новые interfaces
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries infrastructure. Application не строит Prisma queries: use case и связный CRUD
по [Data](conventions/data.md); use case получает данные через repository и сам service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется. на каждую таблицу не требуется.
DTO остаются транспортными runtime classes; существующее использование DTO DTO остаются транспортными runtime classes; существующее использование DTO
на application boundary допустимо по [HTTP API](conventions/http-api.md). на application boundary допустимо по [HTTP API](conventions/http-api.md).
+10 -10
View File
@@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
не становятся domain entities: расчёт получает только нужные ему значения. не становятся domain entities: расчёт получает только нужные ему значения.
Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction Application-код не импортирует PrismaService и не строит Prisma queries.
services (auth, lifecycle подключения, изменения карт/операций/категорий) Репозиторий владеет запросом и CAS-проверкой результата, а application —
сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует правилами и формированием ответа. Типы Prisma допустимы только в type-only
лишь набора pass-through методов. Новая самостоятельная read/write capability импортах для существующей транзакционной границы. Не создавай обёртку
получает repository method; самостоятельный use case не выполняет Prisma queries. на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write`
`BankCardsRepository.write` сохраняет общую retry/transaction boundary для сохраняет retry/transaction boundary для банковских записей; callback с
существующих banking services. Такой callback с Prisma transaction client — transaction client — локальная техническая граница, не API для domain.
локальная техническая граница этих services, не API для domain или use cases.
Выбирай только нужные данные, сохраняй user/account ownership в запросах и Выбирай только нужные данные, сохраняй user/account ownership в запросах и
application checks. Предварительный lookup не заменяет unique constraint при race. application checks. Предварительный lookup не заменяет unique constraint при race.
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany` Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет
для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case. conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP
error contract. Сначала пойми invariant; не копируй его flow в другой use case.
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
+3 -2
View File
@@ -158,13 +158,14 @@ export default defineConfig(
}, },
}, },
{ {
files: ['src/**/*.use-case.ts'], files: ['src/**/application/**/*.ts'],
ignores: tests, ignores: tests,
rules: { rules: {
'no-restricted-imports': ['error', { 'no-restricted-imports': ['error', {
patterns: [{ patterns: [{
group: prismaImports, group: prismaImports,
message: 'Use cases obtain data through concrete repositories.', allowTypeImports: true,
message: 'Application code obtains data through concrete repositories; type-only imports are allowed.',
}], }],
}], }],
}, },
@@ -2,6 +2,8 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { AuthRepository } from '../infrastructure/auth.repository';
jest.mock('bcrypt', () => ({ jest.mock('bcrypt', () => ({
hash: jest.fn(() => Promise.resolve('password-hash')), hash: jest.fn(() => Promise.resolve('password-hash')),
compare: jest.fn(() => Promise.resolve(true)), compare: jest.fn(() => Promise.resolve(true)),
@@ -51,7 +53,7 @@ describe('AuthService', () => {
delete: jest.fn(), delete: jest.fn(),
}, },
}; };
const service = new AuthService(prisma as never, jwt); const service = new AuthService(new AuthRepository(prisma as never), jwt);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
+15 -60
View File
@@ -7,15 +7,13 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../../../generated/prisma/client'; import type { Session, User } from '../../../../generated/prisma/client';
import { HttpErrorCode } from '../../../common/error-code'; import { HttpErrorCode } from '../../../common/error-code';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; import { AuthRepository } from '../infrastructure/auth.repository';
import { PrismaService } from '../../../prisma/prisma.service';
import { AuthErrorCode } from './auth-error-code'; import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1;
const PASSWORD_HASH_ROUNDS = 12; const PASSWORD_HASH_ROUNDS = 12;
export const REFRESH_TTL_MS = 604_800_000; export const REFRESH_TTL_MS = 604_800_000;
@@ -28,7 +26,7 @@ const DUMMY_PASSWORD_HASH =
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly repository: AuthRepository,
private readonly jwt: JwtService, private readonly jwt: JwtService,
) {} ) {}
@@ -36,28 +34,19 @@ export class AuthService {
email: string, email: string,
password: string, password: string,
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
const existing = await this.prisma.user.findUnique({ const existing = await this.repository.findUserByEmail(email);
where: { email },
});
if (existing) { if (existing) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
} }
let user: User; const user = await this.repository.createUser({
try {
user = await this.prisma.user.create({
data: {
id: randomUUID(), id: randomUUID(),
email, email,
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
},
}); });
} catch (error) { if (!user) {
if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
} }
throw error;
}
return this.createSession(user); return this.createSession(user);
} }
@@ -65,9 +54,7 @@ export class AuthService {
email: string, email: string,
password: string, password: string,
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
const user = await this.prisma.user.findUnique({ const user = await this.repository.findUserByEmail(email);
where: { email },
});
const valid = await bcrypt.compare( const valid = await bcrypt.compare(
password, password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH, user?.passwordHash ?? DUMMY_PASSWORD_HASH,
@@ -92,10 +79,7 @@ export class AuthService {
if (payload.type !== 'refresh') { if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
const session = await this.prisma.session.findUnique({ const session = await this.repository.findSessionWithUser(payload.sid);
where: { id: payload.sid },
include: { user: true },
});
if ( if (
!session || !session ||
session.userId !== payload.sub || session.userId !== payload.sub ||
@@ -103,7 +87,7 @@ export class AuthService {
session.expiresAt <= new Date() session.expiresAt <= new Date()
) { ) {
if (session && !session.revokedAt) { if (session && !session.revokedAt) {
await this.prisma.session.delete({ where: { id: session.id } }); await this.repository.deleteSession(session.id);
} }
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
@@ -124,18 +108,13 @@ export class AuthService {
return; return;
} }
if (payload.type !== 'refresh') return; if (payload.type !== 'refresh') return;
const session = await this.prisma.session.findUnique({ const session = await this.repository.findSession(payload.sid);
where: { id: payload.sid },
});
if ( if (
session && session &&
!session.revokedAt && !session.revokedAt &&
session.refreshTokenHash === this.hash(token) session.refreshTokenHash === this.hash(token)
) { ) {
await this.prisma.session.update({ await this.repository.revokeSession(session.id);
where: { id: session.id },
data: { revokedAt: new Date() },
});
} }
} }
@@ -144,7 +123,7 @@ export class AuthService {
} }
async userById(id: string): Promise<User> { async userById(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.repository.findUserById(id);
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return user; return user;
} }
@@ -152,14 +131,7 @@ export class AuthService {
private async createSession( private async createSession(
user: User, user: User,
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
const session = await this.prisma.session.create({ const session = await this.repository.createSession(user.id, randomUUID());
data: {
id: randomUUID(),
userId: user.id,
refreshTokenHash: '',
expiresAt: new Date(),
},
});
return this.rotateSession({ ...session, user }); return this.rotateSession({ ...session, user });
} }
@@ -180,17 +152,9 @@ export class AuthService {
refreshTokenHash: this.hash(refreshToken), refreshTokenHash: this.hash(refreshToken),
expiresAt: new Date(Date.now() + REFRESH_TTL_MS), expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
}; };
if (expectedRefreshTokenHash) { if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) {
const updated = await this.prisma.session.updateMany({
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data,
});
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
} else {
await this.prisma.session.update({ where: { id: session.id }, data });
}
return { return {
user: this.toUser(session.user), user: this.toUser(session.user),
accessToken: await this.jwt.signAsync( accessToken: await this.jwt.signAsync(
@@ -205,12 +169,3 @@ export class AuthService {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
} }
function isUniqueConstraintError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
);
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { AuthService } from './application/auth.service'; import { AuthService } from './application/auth.service';
import { AuthRepository } from './infrastructure/auth.repository';
import { JwtStrategy } from './infrastructure/jwt.strategy'; import { JwtStrategy } from './infrastructure/jwt.strategy';
import { AuthController } from './presentation/auth.controller'; import { AuthController } from './presentation/auth.controller';
import { JwtAuthGuard } from './presentation/jwt-auth.guard'; import { JwtAuthGuard } from './presentation/jwt-auth.guard';
@@ -22,6 +23,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard';
], ],
exports: [JwtAuthGuard], exports: [JwtAuthGuard],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtAuthGuard], providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard],
}) })
export class AuthModule {} export class AuthModule {}
@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
import { PrismaService } from '../../../prisma/prisma.service';
const EXPECTED_ROTATED_SESSIONS = 1;
@Injectable()
export class AuthRepository {
constructor(private readonly prisma: PrismaService) {}
findUserByEmail(email: string) {
return this.prisma.user.findUnique({ where: { email } });
}
async createUser(data: { email: string; id: string; passwordHash: string }) {
try {
return await this.prisma.user.create({ data });
} catch (error) {
if (isUniqueConstraintError(error)) return null;
throw error;
}
}
findUserById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
findSessionWithUser(id: string) {
return this.prisma.session.findUnique({
where: { id },
include: { user: true },
});
}
findSession(id: string) {
return this.prisma.session.findUnique({ where: { id } });
}
deleteSession(id: string) {
return this.prisma.session.delete({ where: { id } });
}
revokeSession(id: string) {
return this.prisma.session.update({
where: { id },
data: { revokedAt: new Date() },
});
}
createSession(userId: string, id: string) {
return this.prisma.session.create({
data: { id, userId, refreshTokenHash: '', expiresAt: new Date() },
});
}
async rotateSession(
id: string,
data: { expiresAt: Date; refreshTokenHash: string },
expectedRefreshTokenHash?: string,
): Promise<boolean> {
if (expectedRefreshTokenHash) {
const updated = await this.prisma.session.updateMany({
where: { id, refreshTokenHash: expectedRefreshTokenHash },
data,
});
return updated.count === EXPECTED_ROTATED_SESSIONS;
}
await this.prisma.session.update({ where: { id }, data });
return true;
}
}
function isUniqueConstraintError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
);
}
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BankCard, Prisma } from '../../../../../generated/prisma/client'; import type { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { bankBalance } from '../domain/bank-card.calculations'; import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money'; import { rubMoney } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
@@ -20,24 +20,13 @@ import {
missing, missing,
} from './bank-card-input'; } from './bank-card-input';
const EXPECTED_UPDATED_CARDS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankCardsService { export class BankCardsService {
constructor( constructor(private readonly repository: BankCardsRepository) {}
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> { private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await tx.bankCardOperation.findMany({ const rows = await this.repository.cardOperations(tx, card);
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
return { return {
id: card.id, id: card.id,
name: card.name, name: card.name,
@@ -53,20 +42,15 @@ export class BankCardsService {
} }
list(userId: string, includeArchived = false) { list(userId: string, includeArchived = false) {
return this.prisma.$transaction(async (tx) => return this.repository.read(async (tx) =>
Promise.all( Promise.all((await this.repository.cards(tx, userId, includeArchived)).map(
( (card) => this.cardView(tx, card),
await tx.bankCard.findMany({ )),
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
).map((card) => this.cardView(tx, card)),
),
); );
} }
get(userId: string, id: string) { get(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => return this.repository.read(async (tx) =>
this.cardView(tx, await this.repository.card(tx, userId, id)), this.cardView(tx, await this.repository.card(tx, userId, id)),
); );
} }
@@ -75,7 +59,7 @@ export class BankCardsService {
const input = cardInput(dto), const input = cardInput(dto),
hash = fingerprint(input); hash = fingerprint(input);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } }); const existing = await this.repository.cardById(tx, dto.id);
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); if (existing.userId !== userId) missing();
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
@@ -88,8 +72,11 @@ export class BankCardsService {
await this.repository.seed(tx, userId); await this.repository.seed(tx, userId);
return this.cardView( return this.cardView(
tx, tx,
await tx.bankCard.create({ await this.repository.createCard(tx, {
data: { ...input, id: dto.id, userId, createFingerprint: hash }, ...input,
id: dto.id,
userId,
createFingerprint: hash,
}), }),
); );
}); });
@@ -122,20 +109,16 @@ export class BankCardsService {
} }
currentDate(input.openingDate); currentDate(input.openingDate);
if ( if (
await tx.bankCardOperation.findFirst({ await this.repository.hasOperationsBefore(tx, userId, id, input.openingDate)
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
) { ) {
fail(BankErrorCode.OpeningDateConflict); fail(BankErrorCode.OpeningDateConflict);
} }
const changed = await tx.bankCard.updateMany({ const updated = await this.repository.updateCard(
where: { id, userId, version: dto.version }, tx,
data: { userId,
id,
dto.version,
{
...input, ...input,
paymentSystem: input.paymentSystem ?? null, paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 }, version: { increment: 1 },
@@ -147,9 +130,8 @@ export class BankCardsService {
: null, : null,
}), }),
}, },
}); );
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict(); return this.cardView(tx, updated);
return this.cardView(tx, await this.repository.card(tx, userId, id));
}); });
} }
} }
@@ -1,10 +1,10 @@
import { GoneException, Injectable } from '@nestjs/common'; import { GoneException, Injectable } from '@nestjs/common';
import { import type {
BankCardOperation, BankCardOperation,
Prisma, Prisma,
} from '../../../../../generated/prisma/client'; } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { FIRST_PAGE } from '../domain/bank-card-limits'; import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money'; import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
@@ -28,15 +28,10 @@ import {
periodBounds, periodBounds,
} from './bank-card-input'; } from './bank-card-input';
const EXPECTED_UPDATED_ROWS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankOperationsService { export class BankOperationsService {
constructor( constructor(private readonly repository: BankCardsRepository) {}
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async operationView( private async operationView(
tx: Tx, tx: Tx,
@@ -51,21 +46,14 @@ export class BankOperationsService {
: null; : null;
const categoryId = source?.categoryId ?? row.categoryId; const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId const category = categoryId
? await tx.expenseCategory.findFirst({ ? await this.repository.category(tx, row.userId, categoryId)
where: { id: categoryId, userId: row.userId },
})
: null; : null;
const refunds = const refunds = [] as Awaited<ReturnType<BankCardsRepository['refunds']>>;
row.kind === BankOperationKind.Expense || source if (row.kind === BankOperationKind.Expense || source) {
? await tx.bankCardOperation.findMany({ refunds.push(...await this.repository.refunds(
where: { tx, row.userId, source?.id ?? row.id, source?.id ? row.id : undefined,
userId: row.userId, ));
originalExpenseId: source?.id ?? row.id, }
deletedAt: null,
...(source ? { id: { not: row.id } } : {}),
},
})
: [];
return { return {
id: row.id, id: row.id,
kind: row.kind as BankOperationDto['kind'], kind: row.kind as BankOperationDto['kind'],
@@ -94,51 +82,7 @@ export class BankOperationsService {
private async pageViews(tx: Tx, rows: BankCardOperation[]) { private async pageViews(tx: Tx, rows: BankCardOperation[]) {
const userId = rows[0]?.userId; const userId = rows[0]?.userId;
if (!userId) return []; if (!userId) return [];
const cardIds = new Set( const { cards, sources, categories, refunds } = await this.repository.operationViewData(tx, userId, rows);
rows.flatMap((row) =>
[row.cardId, row.targetCardId].filter(
(id): id is string => id !== null,
),
),
);
const sourceIds = new Set(
rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])),
);
const [cards, sources] = await Promise.all([
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
tx.bankCardOperation.findMany({
where: { userId, id: { in: [...sourceIds] } },
}),
]);
const categoryIds = new Set(
rows
.map(
(row) =>
sources.find((source) => source.id === row.originalExpenseId)
?.categoryId ?? row.categoryId,
)
.filter((id): id is string => id !== null),
);
const expenseIds = new Set(
rows.flatMap((row) => {
const source = sources.find((item) => item.id === row.originalExpenseId);
return row.kind === BankOperationKind.Expense || source
? [source?.id ?? row.id]
: [];
}),
);
const [categories, refunds] = await Promise.all([
tx.expenseCategory.findMany({
where: { userId, id: { in: [...categoryIds] } },
}),
tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId: { in: [...expenseIds] },
deletedAt: null,
},
}),
]);
const cardsById = new Map(cards.map((card) => [card.id, card])); const cardsById = new Map(cards.map((card) => [card.id, card]));
const sourcesById = new Map(sources.map((source) => [source.id, source])); const sourcesById = new Map(sources.map((source) => [source.id, source]));
const categoriesById = new Map(categories.map((category) => [category.id, category])); const categoriesById = new Map(categories.map((category) => [category.id, category]));
@@ -227,16 +171,12 @@ export class BankOperationsService {
if (input.kind === BankOperationKind.Expense) { if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail(); if (input.originalExpenseId) fail();
if (!input.categoryId) { if (!input.categoryId) {
const category = await tx.expenseCategory.findUnique({ const category = await this.repository.defaultCategory(tx, userId);
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
if (!category) missing(); if (!category) missing();
input.categoryId = category.id; input.categoryId = category.id;
} }
if ( if (
!(await tx.expenseCategory.findFirst({ !(await this.repository.category(tx, userId, input.categoryId))
where: { userId, id: input.categoryId },
}))
) { ) {
missing(); missing();
} }
@@ -258,29 +198,11 @@ export class BankOperationsService {
) { ) {
fail(BankErrorCode.InvalidRefund); fail(BankErrorCode.InvalidRefund);
} }
if ( await this.repository.updateOperation(tx,
( { id: source.id, userId, version: source.version, deletedAt: null },
await tx.bankCardOperation.updateMany({ { version: { increment: 1 } },
where: { );
id: source.id, const refunds = await this.repository.refunds(tx, userId, source.id, old?.id);
userId,
version: source.version,
deletedAt: null,
},
data: { version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
const refunds = await tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId: source.id,
deletedAt: null,
...(old ? { id: { not: old.id } } : {}),
},
});
if (input.date < source.date) fail(BankErrorCode.InvalidDate); if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if ( if (
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
@@ -291,9 +213,7 @@ export class BankOperationsService {
} }
} }
if (old?.kind === BankOperationKind.Expense) { if (old?.kind === BankOperationKind.Expense) {
const refunds = await tx.bankCardOperation.findMany({ const refunds = await this.repository.refunds(tx, userId, old.id);
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
if ( if (
refunds.length && refunds.length &&
(input.cardId !== old.cardId || (input.cardId !== old.cardId ||
@@ -310,9 +230,7 @@ export class BankOperationsService {
const normalized = operationInput(dto), const normalized = operationInput(dto),
hash = fingerprint(normalized); hash = fingerprint(normalized);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const existing = await tx.bankCardOperation.findUnique({ const existing = await this.repository.operationById(tx, dto.id);
where: { id: dto.id },
});
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); if (existing.userId !== userId) missing();
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
@@ -328,15 +246,15 @@ export class BankOperationsService {
await this.validateOperation(tx, userId, input); await this.validateOperation(tx, userId, input);
return this.operationView( return this.operationView(
tx, tx,
await tx.bankCardOperation.create({ await this.repository.createOperation(tx, {
data: { ...input, id: dto.id, userId, createFingerprint: hash }, ...input, id: dto.id, userId, createFingerprint: hash,
}), }),
); );
}); });
} }
getOperation(userId: string, id: string) { getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => { return this.repository.read(async (tx) => {
const row = await this.repository.operation(tx, userId, id); const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) missing(); if (row.deletedAt) missing();
return this.operationView(tx, row); return this.operationView(tx, row);
@@ -361,16 +279,10 @@ export class BankOperationsService {
description: dto.description ?? old.description ?? undefined, description: dto.description ?? old.description ?? undefined,
}); });
await this.validateOperation(tx, userId, input, old); await this.validateOperation(tx, userId, input, old);
if ( await this.repository.updateOperation(tx,
( { id, userId, version: dto.version, deletedAt: null },
await tx.bankCardOperation.updateMany({ { ...input, version: { increment: 1 } },
where: { id, userId, version: dto.version, deletedAt: null }, );
data: { ...input, version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
return this.operationView( return this.operationView(
tx, tx,
await this.repository.operation(tx, userId, id), await this.repository.operation(tx, userId, id),
@@ -389,9 +301,7 @@ export class BankOperationsService {
} }
if ( if (
row.kind === BankOperationKind.Expense && row.kind === BankOperationKind.Expense &&
(await tx.bankCardOperation.findFirst({ (await this.repository.dependentRefund(tx, userId, id))
where: { userId, originalExpenseId: id, deletedAt: null },
}))
) { ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
} }
@@ -401,27 +311,15 @@ export class BankOperationsService {
userId, userId,
row.originalExpenseId, row.originalExpenseId,
); );
if ( await this.repository.updateOperation(tx,
( { id: source.id, userId, version: source.version },
await tx.bankCardOperation.updateMany({ { version: { increment: 1 } },
where: { id: source.id, userId, version: source.version }, );
data: { version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
}
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
} }
await this.repository.updateOperation(tx,
{ id, userId, version, deletedAt: null },
{ deletedAt: new Date(), version: { increment: 1 } },
);
}); });
} }
@@ -434,25 +332,13 @@ export class BankOperationsService {
const period = expenses const period = expenses
? undefined ? undefined
: periodBounds(query as BankOperationsQueryDto); : periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => { return this.repository.read(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
page = query.page ?? FIRST_PAGE; page = query.page ?? FIRST_PAGE;
const where: Prisma.BankCardOperationWhereInput = { const { total, rows } = await this.repository.operationPage(
userId, tx, userId, id, period, limit, page,
deletedAt: null, );
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - FIRST_PAGE) * limit,
take: limit,
});
return { return {
items: await this.pageViews(tx, rows), items: await this.pageViews(tx, rows),
total, total,
@@ -1,81 +1,24 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service';
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { conflict, missing, name } from './bank-card-input'; import { name } from './bank-card-input';
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
@Injectable() @Injectable()
export class ExpenseCategoriesService { export class ExpenseCategoriesService {
constructor( constructor(private readonly repository: BankCardsRepository) {}
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
async categories(userId: string) { async categories(userId: string) {
return ( return this.repository.categories(userId);
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
} }
createCategory(userId: string, value: string) { createCategory(userId: string, value: string) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.createCategory(userId, label);
await this.repository.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
} }
updateCategory(userId: string, id: string, value: string, version: number) { updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.updateCategory(userId, id, label, version);
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== EXPECTED_UPDATED_CATEGORIES
) {
conflict();
}
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + VERSION_INCREMENT,
};
});
} }
} }
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client'; import { BankCard, BankCardOperation, Prisma } from '../../../../../generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code'; import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { import {
@@ -9,10 +9,15 @@ import {
conflict, conflict,
missing, missing,
} from '../application/bank-card-input'; } from '../application/bank-card-input';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
const INITIAL_RETRY_COUNT = 0; const INITIAL_RETRY_COUNT = 0;
const LAST_SEED_OFFSET = 1; const LAST_SEED_OFFSET = 1;
const EXPECTED_UPDATED_CARDS = 1;
const EXPECTED_UPDATED_OPERATIONS = 1;
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_MAX_WAIT_MS = 10_000;
@@ -32,6 +37,220 @@ const seeds = [
@Injectable() @Injectable()
export class BankCardsRepository { export class BankCardsRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
read<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
return this.prisma.$transaction(run);
}
cards(tx: Tx, userId: string, includeArchived = false) {
return tx.bankCard.findMany({
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
}
cardOperations(tx: Tx, card: BankCard) {
return tx.bankCardOperation.findMany({
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
}
operationById(tx: Tx, id: string) {
return tx.bankCardOperation.findUnique({ where: { id } });
}
createOperation(tx: Tx, data: Prisma.BankCardOperationUncheckedCreateInput) {
return tx.bankCardOperation.create({ data });
}
async updateOperation(
tx: Tx,
where: Prisma.BankCardOperationWhereInput,
data: Prisma.BankCardOperationUpdateManyMutationInput,
) {
const result = await tx.bankCardOperation.updateMany({ where, data });
if (result.count !== EXPECTED_UPDATED_OPERATIONS) conflict();
}
category(tx: Tx, userId: string, id: string) {
return tx.expenseCategory.findFirst({ where: { id, userId } });
}
defaultCategory(tx: Tx, userId: string) {
return tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
}
refunds(tx: Tx, userId: string, originalExpenseId: string, excludeId?: string) {
return tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId,
deletedAt: null,
...(excludeId ? { id: { not: excludeId } } : {}),
},
});
}
dependentRefund(tx: Tx, userId: string, originalExpenseId: string) {
return tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId, deletedAt: null },
});
}
async operationViewData(tx: Tx, userId: string, rows: BankCardOperation[]) {
const cardIds = new Set(rows.flatMap((row) =>
[row.cardId, row.targetCardId].filter((id): id is string => id !== null),
));
const sourceIds = new Set(rows.flatMap((row) =>
row.originalExpenseId ? [row.originalExpenseId] : [],
));
const [cards, sources] = await Promise.all([
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
tx.bankCardOperation.findMany({ where: { userId, id: { in: [...sourceIds] } } }),
]);
const categoryIds = new Set(rows.map((row) =>
sources.find((source) => source.id === row.originalExpenseId)?.categoryId ?? row.categoryId,
).filter((id): id is string => id !== null));
const expenseIds = new Set(rows.flatMap((row) => {
const source = sources.find((item) => item.id === row.originalExpenseId);
return row.kind === BankOperationKind.Expense || source ? [source?.id ?? row.id] : [];
}));
const [categories, refunds] = await Promise.all([
tx.expenseCategory.findMany({ where: { userId, id: { in: [...categoryIds] } } }),
tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: { in: [...expenseIds] }, deletedAt: null },
}),
]);
return { cards, sources, categories, refunds };
}
async operationPage(
tx: Tx,
userId: string,
id: string,
period: undefined | { from: string; to: string },
limit: number,
page: number,
) {
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - FIRST_PAGE) * limit,
take: limit,
});
return { total, rows };
}
cardById(tx: Tx, id: string) {
return tx.bankCard.findUnique({ where: { id } });
}
createCard(tx: Tx, data: Prisma.BankCardUncheckedCreateInput) {
return tx.bankCard.create({ data });
}
async updateCard(
tx: Tx,
userId: string,
id: string,
version: number,
data: Prisma.BankCardUpdateManyMutationInput,
) {
const changed = await tx.bankCard.updateMany({
where: { id, userId, version },
data,
});
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.card(tx, userId, id);
}
hasOperationsBefore(tx: Tx, userId: string, id: string, openingDate: string) {
return tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
});
}
async categories(userId: string) {
return (
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
}
createCategory(userId: string, label: string) {
return this.write(async (tx) => {
await this.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
}
updateCategory(userId: string, id: string, label: string, version: number) {
return this.write(async (tx) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== EXPECTED_UPDATED_CATEGORIES
) {
conflict();
}
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + VERSION_INCREMENT,
};
});
}
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> { async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) { for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
try { try {
+2 -1
View File
@@ -102,6 +102,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur
[controller, 'import { PrismaService } from "../../../prisma/prisma.service";'], [controller, 'import { PrismaService } from "../../../prisma/prisma.service";'],
[controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'], [controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'],
[useCase, 'import { PrismaService } from "../../../../prisma/prisma.service";'], [useCase, 'import { PrismaService } from "../../../../prisma/prisma.service";'],
['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'],
]) { ]) {
const [result] = await eslint.lintText(code, { filePath }); const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0); assert.equal(result.fatalErrorCount, 0);
@@ -113,7 +114,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur
[controller, 'import { AuthService } from "../application/auth.service";'], [controller, 'import { AuthService } from "../application/auth.service";'],
[useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'], [useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'],
[useCase, 'import { BankSummaryQueryDto } from "../presentation/bank-card.dto";'], [useCase, 'import { BankSummaryQueryDto } from "../presentation/bank-card.dto";'],
['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'], ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import type { Prisma } from "../../../../../generated/prisma/client";'],
]) { ]) {
const [result] = await eslint.lintText(code, { filePath }); const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0); assert.equal(result.fatalErrorCount, 0);