diff --git a/.ai/architecture.md b/.ai/architecture.md index a0440a2..78a0c26 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными domain из-за extraction в функцию. Boundary mappers преобразуют внешние records; чистые расчёты portfolio/banking остаются у domain owner. -PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные -через concrete repositories; существующие связные CRUD/transaction services -сохраняют прямые queries по [data convention](conventions/data.md). +PrismaService владеет DB lifecycle. Application services/use cases получают +данные через concrete repositories; запросы и CAS живут в infrastructure +по [data convention](conventions/data.md). Money — currency + decimal string; расчёты используют точные общие helpers. Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и @@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio конкретные infrastructure providers, зарегистрированные module. Infrastructure может использовать application types/input и domain rules; новые interfaces ради инверсии зависимостей не обязательны. Domain не импортирует presentation или -infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries -по [Data](conventions/data.md); use case получает данные через repository и сам -Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется. +infrastructure. Application не строит Prisma queries: use case и связный CRUD +service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface +на каждую таблицу не требуется. DTO остаются транспортными runtime classes; существующее использование DTO на application boundary допустимо по [HTTP API](conventions/http-api.md). diff --git a/.ai/conventions/data.md b/.ai/conventions/data.md index b79cc76..ea74b2f 100644 --- a/.ai/conventions/data.md +++ b/.ai/conventions/data.md @@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records не становятся domain entities: расчёт получает только нужные ему значения. -Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction -services (auth, lifecycle подключения, изменения карт/операций/категорий) -сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует -лишь набора pass-through методов. Новая самостоятельная read/write capability -получает repository method; самостоятельный use case не выполняет Prisma queries. -`BankCardsRepository.write` сохраняет общую retry/transaction boundary для -существующих banking services. Такой callback с Prisma transaction client — -локальная техническая граница этих services, не API для domain или use cases. +Application-код не импортирует PrismaService и не строит Prisma queries. +Репозиторий владеет запросом и CAS-проверкой результата, а application — +правилами и формированием ответа. Типы Prisma допустимы только в type-only +импортах для существующей транзакционной границы. Не создавай обёртку +на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write` +сохраняет retry/transaction boundary для банковских записей; callback с +transaction client — локальная техническая граница, не API для domain. Выбирай только нужные данные, сохраняй user/account ownership в запросах и application checks. Предварительный lookup не заменяет unique constraint при race. -Пример: AuthService обрабатывает P2002 и использует conditional `updateMany` -для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case. +Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет +conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP +error contract. Сначала пойми invariant; не копируй его flow в другой use case. Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB операция. Планируй порядок и compensation по текущей задаче; не удерживай DB diff --git a/eslint.config.mjs b/eslint.config.mjs index d5a9e23..b44f066 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -158,13 +158,14 @@ export default defineConfig( }, }, { - files: ['src/**/*.use-case.ts'], + files: ['src/**/application/**/*.ts'], ignores: tests, rules: { 'no-restricted-imports': ['error', { patterns: [{ 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.', }], }], }, diff --git a/src/modules/auth/application/auth.service.spec.ts b/src/modules/auth/application/auth.service.spec.ts index 3e04a6d..b9e4930 100644 --- a/src/modules/auth/application/auth.service.spec.ts +++ b/src/modules/auth/application/auth.service.spec.ts @@ -2,6 +2,8 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { createHash } from 'crypto'; +import { AuthRepository } from '../infrastructure/auth.repository'; + jest.mock('bcrypt', () => ({ hash: jest.fn(() => Promise.resolve('password-hash')), compare: jest.fn(() => Promise.resolve(true)), @@ -51,7 +53,7 @@ describe('AuthService', () => { delete: jest.fn(), }, }; - const service = new AuthService(prisma as never, jwt); + const service = new AuthService(new AuthRepository(prisma as never), jwt); beforeEach(() => { jest.clearAllMocks(); diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index f19cbc2..2462d24 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -7,15 +7,13 @@ import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; 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 { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; -import { PrismaService } from '../../../prisma/prisma.service'; +import { AuthRepository } from '../infrastructure/auth.repository'; import { AuthErrorCode } from './auth-error-code'; import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; -const EXPECTED_ROTATED_SESSIONS = 1; - const PASSWORD_HASH_ROUNDS = 12; export const REFRESH_TTL_MS = 604_800_000; @@ -28,7 +26,7 @@ const DUMMY_PASSWORD_HASH = @Injectable() export class AuthService { constructor( - private readonly prisma: PrismaService, + private readonly repository: AuthRepository, private readonly jwt: JwtService, ) {} @@ -36,27 +34,18 @@ export class AuthService { email: string, password: string, ): Promise { - const existing = await this.prisma.user.findUnique({ - where: { email }, - }); + const existing = await this.repository.findUserByEmail(email); if (existing) { throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } - let user: User; - try { - user = await this.prisma.user.create({ - data: { - id: randomUUID(), - email, - passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), - }, - }); - } catch (error) { - if (isUniqueConstraintError(error)) { - throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); - } - throw error; + const user = await this.repository.createUser({ + id: randomUUID(), + email, + passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), + }); + if (!user) { + throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } return this.createSession(user); } @@ -65,9 +54,7 @@ export class AuthService { email: string, password: string, ): Promise { - const user = await this.prisma.user.findUnique({ - where: { email }, - }); + const user = await this.repository.findUserByEmail(email); const valid = await bcrypt.compare( password, user?.passwordHash ?? DUMMY_PASSWORD_HASH, @@ -92,10 +79,7 @@ export class AuthService { if (payload.type !== 'refresh') { throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } - const session = await this.prisma.session.findUnique({ - where: { id: payload.sid }, - include: { user: true }, - }); + const session = await this.repository.findSessionWithUser(payload.sid); if ( !session || session.userId !== payload.sub || @@ -103,7 +87,7 @@ export class AuthService { session.expiresAt <= new Date() ) { 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 }); } @@ -124,18 +108,13 @@ export class AuthService { return; } if (payload.type !== 'refresh') return; - const session = await this.prisma.session.findUnique({ - where: { id: payload.sid }, - }); + const session = await this.repository.findSession(payload.sid); if ( session && !session.revokedAt && session.refreshTokenHash === this.hash(token) ) { - await this.prisma.session.update({ - where: { id: session.id }, - data: { revokedAt: new Date() }, - }); + await this.repository.revokeSession(session.id); } } @@ -144,7 +123,7 @@ export class AuthService { } async userById(id: string): Promise { - const user = await this.prisma.user.findUnique({ where: { id } }); + const user = await this.repository.findUserById(id); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return user; } @@ -152,14 +131,7 @@ export class AuthService { private async createSession( user: User, ): Promise { - const session = await this.prisma.session.create({ - data: { - id: randomUUID(), - userId: user.id, - refreshTokenHash: '', - expiresAt: new Date(), - }, - }); + const session = await this.repository.createSession(user.id, randomUUID()); return this.rotateSession({ ...session, user }); } @@ -180,16 +152,8 @@ export class AuthService { refreshTokenHash: this.hash(refreshToken), expiresAt: new Date(Date.now() + REFRESH_TTL_MS), }; - if (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 }); - } - } else { - await this.prisma.session.update({ where: { id: session.id }, data }); + if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) { + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } return { user: this.toUser(session.user), @@ -205,12 +169,3 @@ export class AuthService { 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) - ); -} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1d93f41..ccef8e5 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport'; import { Environment } from '../../config/env.validation'; import { AuthService } from './application/auth.service'; +import { AuthRepository } from './infrastructure/auth.repository'; import { JwtStrategy } from './infrastructure/jwt.strategy'; import { AuthController } from './presentation/auth.controller'; import { JwtAuthGuard } from './presentation/jwt-auth.guard'; @@ -22,6 +23,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard'; ], exports: [JwtAuthGuard], controllers: [AuthController], - providers: [AuthService, JwtStrategy, JwtAuthGuard], + providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard], }) export class AuthModule {} diff --git a/src/modules/auth/infrastructure/auth.repository.ts b/src/modules/auth/infrastructure/auth.repository.ts new file mode 100644 index 0000000..afc3515 --- /dev/null +++ b/src/modules/auth/infrastructure/auth.repository.ts @@ -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 { + 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) + ); +} diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index e1e6d74..e8db044 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { BankCard, Prisma } from '../../../../../generated/prisma/client'; -import { PrismaService } from '../../../../prisma/prisma.service'; +import type { BankCard, Prisma } from '../../../../../generated/prisma/client'; + import { bankBalance } from '../domain/bank-card.calculations'; import { rubMoney } from '../domain/rub-money'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; @@ -20,24 +20,13 @@ import { missing, } from './bank-card-input'; -const EXPECTED_UPDATED_CARDS = 1; - type Tx = Prisma.TransactionClient; @Injectable() export class BankCardsService { - constructor( - private readonly prisma: PrismaService, - private readonly repository: BankCardsRepository, - ) {} + constructor(private readonly repository: BankCardsRepository) {} private async cardView(tx: Tx, card: BankCard): Promise { - const rows = await tx.bankCardOperation.findMany({ - where: { - userId: card.userId, - deletedAt: null, - OR: [{ cardId: card.id }, { targetCardId: card.id }], - }, - }); + const rows = await this.repository.cardOperations(tx, card); return { id: card.id, name: card.name, @@ -53,20 +42,15 @@ export class BankCardsService { } list(userId: string, includeArchived = false) { - return this.prisma.$transaction(async (tx) => - Promise.all( - ( - await tx.bankCard.findMany({ - where: { userId, ...(includeArchived ? {} : { archivedAt: null }) }, - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], - }) - ).map((card) => this.cardView(tx, card)), - ), + return this.repository.read(async (tx) => + Promise.all((await this.repository.cards(tx, userId, includeArchived)).map( + (card) => this.cardView(tx, card), + )), ); } 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)), ); } @@ -75,7 +59,7 @@ export class BankCardsService { const input = cardInput(dto), hash = fingerprint(input); 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.userId !== userId) missing(); // 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); return this.cardView( tx, - await tx.bankCard.create({ - data: { ...input, id: dto.id, userId, createFingerprint: hash }, + await this.repository.createCard(tx, { + ...input, + id: dto.id, + userId, + createFingerprint: hash, }), ); }); @@ -122,20 +109,16 @@ export class BankCardsService { } currentDate(input.openingDate); if ( - await tx.bankCardOperation.findFirst({ - where: { - userId, - deletedAt: null, - date: { lt: input.openingDate }, - OR: [{ cardId: id }, { targetCardId: id }], - }, - }) + await this.repository.hasOperationsBefore(tx, userId, id, input.openingDate) ) { fail(BankErrorCode.OpeningDateConflict); } - const changed = await tx.bankCard.updateMany({ - where: { id, userId, version: dto.version }, - data: { + const updated = await this.repository.updateCard( + tx, + userId, + id, + dto.version, + { ...input, paymentSystem: input.paymentSystem ?? null, version: { increment: 1 }, @@ -147,9 +130,8 @@ export class BankCardsService { : null, }), }, - }); - if (changed.count !== EXPECTED_UPDATED_CARDS) conflict(); - return this.cardView(tx, await this.repository.card(tx, userId, id)); + ); + return this.cardView(tx, updated); }); } } diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index 8ffeec3..bd036f0 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -1,10 +1,10 @@ import { GoneException, Injectable } from '@nestjs/common'; -import { +import type { BankCardOperation, Prisma, } from '../../../../../generated/prisma/client'; -import { PrismaService } from '../../../../prisma/prisma.service'; + import { FIRST_PAGE } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; import { @@ -32,15 +32,10 @@ import { periodBounds, } from './bank-card-input'; -const EXPECTED_UPDATED_ROWS = 1; - type Tx = Prisma.TransactionClient; @Injectable() export class BankOperationsService { - constructor( - private readonly prisma: PrismaService, - private readonly repository: BankCardsRepository, - ) {} + constructor(private readonly repository: BankCardsRepository) {} private async operationView( tx: Tx, @@ -52,51 +47,7 @@ export class BankOperationsService { private async pageViews(tx: Tx, rows: BankCardOperation[]) { const userId = rows[0]?.userId; if (!userId) return []; - 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, - }, - }), - ]); + const { cards, sources, categories, refunds } = await this.repository.operationViewData(tx, userId, rows); const cardsById = new Map(cards.map((card) => [card.id, card])); const sourcesById = new Map(sources.map((source) => [source.id, source])); const categoriesById = new Map(categories.map((category) => [category.id, category])); @@ -172,16 +123,12 @@ export class BankOperationsService { if (operationFieldConflict(input)) fail(); if (input.kind === BankOperationKind.Expense) { if (!input.categoryId) { - const category = await tx.expenseCategory.findUnique({ - where: { userId_seedKey: { userId, seedKey: 'other' } }, - }); + const category = await this.repository.defaultCategory(tx, userId); if (!category) missing(); input.categoryId = category.id; } if ( - !(await tx.expenseCategory.findFirst({ - where: { userId, id: input.categoryId }, - })) + !(await this.repository.category(tx, userId, input.categoryId)) ) { missing(); } @@ -199,29 +146,11 @@ export class BankOperationsService { ) { fail(BankErrorCode.InvalidRefund); } - if ( - ( - await tx.bankCardOperation.updateMany({ - where: { - id: source.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 } } : {}), - }, - }); + await this.repository.updateOperation(tx, + { id: source.id, userId, version: source.version, deletedAt: null }, + { version: { increment: 1 } }, + ); + const refunds = await this.repository.refunds(tx, userId, source.id, old?.id); if (input.date < source.date) fail(BankErrorCode.InvalidDate); if ( refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) + @@ -232,9 +161,7 @@ export class BankOperationsService { } } if (old?.kind === BankOperationKind.Expense) { - const refunds = await tx.bankCardOperation.findMany({ - where: { userId, originalExpenseId: old.id, deletedAt: null }, - }); + const refunds = await this.repository.refunds(tx, userId, old.id); if ( refunds.length && (input.cardId !== old.cardId || @@ -251,9 +178,7 @@ export class BankOperationsService { const normalized = operationInput(dto), hash = fingerprint(normalized); return this.repository.write(async (tx) => { - const existing = await tx.bankCardOperation.findUnique({ - where: { id: dto.id }, - }); + const existing = await this.repository.operationById(tx, dto.id); if (existing) { 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. @@ -269,15 +194,15 @@ export class BankOperationsService { await this.validateOperation(tx, userId, input); return this.operationView( tx, - await tx.bankCardOperation.create({ - data: { ...input, id: dto.id, userId, createFingerprint: hash }, + await this.repository.createOperation(tx, { + ...input, id: dto.id, userId, createFingerprint: hash, }), ); }); } 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); if (row.deletedAt) missing(); return this.operationView(tx, row); @@ -302,16 +227,10 @@ export class BankOperationsService { description: dto.description ?? old.description ?? undefined, }); await this.validateOperation(tx, userId, input, old); - if ( - ( - await tx.bankCardOperation.updateMany({ - where: { id, userId, version: dto.version, deletedAt: null }, - data: { ...input, version: { increment: 1 } }, - }) - ).count !== EXPECTED_UPDATED_ROWS - ) { - conflict(); - } + await this.repository.updateOperation(tx, + { id, userId, version: dto.version, deletedAt: null }, + { ...input, version: { increment: 1 } }, + ); return this.operationView( tx, await this.repository.operation(tx, userId, id), @@ -330,9 +249,7 @@ export class BankOperationsService { } if ( row.kind === BankOperationKind.Expense && - (await tx.bankCardOperation.findFirst({ - where: { userId, originalExpenseId: id, deletedAt: null }, - })) + (await this.repository.dependentRefund(tx, userId, id)) ) { conflict(BankErrorCode.DependentRefunds); } @@ -342,27 +259,15 @@ export class BankOperationsService { userId, row.originalExpenseId, ); - if ( - ( - await tx.bankCardOperation.updateMany({ - 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: source.id, userId, version: source.version }, + { version: { increment: 1 } }, + ); } + await this.repository.updateOperation(tx, + { id, userId, version, deletedAt: null }, + { deletedAt: new Date(), version: { increment: 1 } }, + ); }); } @@ -375,25 +280,13 @@ export class BankOperationsService { const period = expenses ? undefined : periodBounds(query as BankOperationsQueryDto); - return this.prisma.$transaction(async (tx) => { + return this.repository.read(async (tx) => { await this.repository.card(tx, userId, id); const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, page = query.page ?? FIRST_PAGE; - 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, - }); + const { total, rows } = await this.repository.operationPage( + tx, userId, id, period, limit, page, + ); return { items: await this.pageViews(tx, rows), total, diff --git a/src/modules/banking/bank-cards/application/expense-categories.service.ts b/src/modules/banking/bank-cards/application/expense-categories.service.ts index 4dbad85..65dadef 100644 --- a/src/modules/banking/bank-cards/application/expense-categories.service.ts +++ b/src/modules/banking/bank-cards/application/expense-categories.service.ts @@ -1,81 +1,24 @@ 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 { BankCardsRepository } from '../infrastructure/bank-cards.repository'; -import { conflict, missing, name } from './bank-card-input'; - -const EXPECTED_UPDATED_CATEGORIES = 1; -const VERSION_INCREMENT = 1; +import { name } from './bank-card-input'; @Injectable() export class ExpenseCategoriesService { - constructor( - private readonly prisma: PrismaService, - private readonly repository: BankCardsRepository, - ) {} + constructor(private readonly repository: BankCardsRepository) {} 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, - })); + return this.repository.categories(userId); } createCategory(userId: string, value: string) { const label = name(value, MAX_CATEGORY_NAME_LENGTH); - return this.repository.write(async (tx) => { - await this.repository.seed(tx, userId); - const row = await tx.expenseCategory.create({ - data: { - id: randomUUID(), - userId, - name: label, - nameKey: label.toLocaleLowerCase('ru-RU'), - }, - }); - return { - id: row.id, - name: row.name, - isDefault: false, - version: row.version, - }; - }); + return this.repository.createCategory(userId, label); } updateCategory(userId: string, id: string, value: string, version: number) { const label = name(value, MAX_CATEGORY_NAME_LENGTH); - return this.repository.write(async (tx) => { - const row = await tx.expenseCategory.findFirst({ where: { id, userId } }); - if (!row) missing(); - 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, - }; - }); + return this.repository.updateCategory(userId, id, label, version); } } diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 4f33ba9..3572f6b 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; 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 { PrismaService } from '../../../../prisma/prisma.service'; import { @@ -9,10 +9,15 @@ import { conflict, missing, } from '../application/bank-card-input'; +import { FIRST_PAGE } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; const INITIAL_RETRY_COUNT = 0; 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; const TRANSACTION_MAX_WAIT_MS = 10_000; @@ -32,6 +37,220 @@ const seeds = [ @Injectable() export class BankCardsRepository { constructor(private readonly prisma: PrismaService) {} + + read(run: (tx: Tx) => Promise): Promise { + 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(run: (tx: Tx) => Promise): Promise { for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) { try { diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index 4a8b9d1..69fb37b 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -102,6 +102,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur [controller, 'import { PrismaService } from "../../../prisma/prisma.service";'], [controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'], [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 }); 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";'], [useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'], [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 }); assert.equal(result.fatalErrorCount, 0);