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..e4bdfe0 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -4,7 +4,6 @@ import { 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 +31,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 +46,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 +122,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 +145,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 +160,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 +177,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 +193,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 +226,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 +248,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 +258,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 +279,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/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 3943cf4..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 { BankCard, 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,11 +9,13 @@ 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; @@ -57,6 +59,103 @@ export class BankCardsRepository { }); } + 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 } }); }