import { GoneException, Injectable } from '@nestjs/common'; 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 { rubMoney, ZERO_KOPECKS } from '../domain/rub-money'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankOperationDto, BankOperationsQueryDto, BankPageQueryDto, CreateBankOperationDto, DEFAULT_BANK_PAGE_SIZE, UpdateBankOperationDto, } from '../presentation/bank-card.dto'; import { BankErrorCode, conflict, currentDate, fail, fingerprint, missing, operationInput, 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, ) {} private async operationView( tx: Tx, row: BankCardOperation, ): Promise { const card = await this.repository.card(tx, row.userId, row.cardId); const target = row.targetCardId ? await this.repository.card(tx, row.userId, row.targetCardId) : null; const source = row.originalExpenseId ? await this.repository.operation(tx, row.userId, row.originalExpenseId) : null; const categoryId = source?.categoryId ?? row.categoryId; const category = categoryId ? await tx.expenseCategory.findFirst({ where: { id: categoryId, userId: row.userId }, }) : null; const refunds = row.kind === BankOperationKind.Expense || source ? await tx.bankCardOperation.findMany({ where: { userId: row.userId, originalExpenseId: source?.id ?? row.id, deletedAt: null, ...(source ? { id: { not: row.id } } : {}), }, }) : []; return { id: row.id, kind: row.kind as BankOperationDto['kind'], cardId: row.cardId, cardName: card.name, targetCardId: row.targetCardId, targetCardName: target?.name ?? null, categoryId, categoryName: category?.name ?? null, originalExpenseId: row.originalExpenseId, amount: rubMoney(row.amountKopecks), availableRefund: row.kind === BankOperationKind.Expense || source ? rubMoney( (source?.amountKopecks ?? row.amountKopecks) - refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS), ) : null, date: row.date, description: row.description, version: row.version, createdAt: row.createdAt.toISOString(), }; } private async validateOperation( tx: Tx, userId: string, input: ReturnType, old?: BankCardOperation, ) { const cards = new Set( [input.cardId, input.targetCardId, old?.cardId, old?.targetCardId].filter( (id): id is string => Boolean(id), ), ); currentDate(input.date); for (const id of cards) { const card = await this.repository.card(tx, userId, id, true); if ( (id === input.cardId || id === input.targetCardId) && input.date < card.openingDate ) { fail(BankErrorCode.InvalidDate); } } if ( old && (old.kind !== input.kind || old.originalExpenseId !== input.originalExpenseId) ) { fail(BankErrorCode.ImmutableOperation); } if (input.kind === BankOperationKind.Transfer) { if ( !input.targetCardId || input.targetCardId === input.cardId || input.categoryId || input.originalExpenseId ) { fail(); } } else if (input.targetCardId) fail(); if (input.kind === BankOperationKind.Expense) { if (input.originalExpenseId) fail(); if (!input.categoryId) { const category = await tx.expenseCategory.findUnique({ where: { userId_seedKey: { userId, seedKey: 'other' } }, }); if (!category) missing(); input.categoryId = category.id; } if ( !(await tx.expenseCategory.findFirst({ where: { userId, id: input.categoryId }, })) ) { missing(); } } else if (input.categoryId) fail(); if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) { fail(); } if (input.kind === BankOperationKind.Refund) { if (!input.originalExpenseId) fail(); const source = await this.repository.operation( tx, userId, input.originalExpenseId, ); if ( source.deletedAt || source.kind !== BankOperationKind.Expense || source.cardId !== input.cardId ) { 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 } } : {}), }, }); if (input.date < source.date) fail(BankErrorCode.InvalidDate); if ( refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) + input.amountKopecks > source.amountKopecks ) { conflict(BankErrorCode.RefundLimit); } } if (old?.kind === BankOperationKind.Expense) { const refunds = await tx.bankCardOperation.findMany({ where: { userId, originalExpenseId: old.id, deletedAt: null }, }); if ( refunds.length && (input.cardId !== old.cardId || refunds.some((row) => row.date < input.date) || refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) > input.amountKopecks) ) { conflict(BankErrorCode.DependentRefunds); } } } createOperation(userId: string, dto: CreateBankOperationDto) { const normalized = operationInput(dto), hash = fingerprint(normalized); return this.repository.write(async (tx) => { const existing = await tx.bankCardOperation.findUnique({ where: { id: 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. if (existing.createFingerprint !== hash) { conflict(BankErrorCode.IntentConflict); } if (existing.deletedAt) { throw new GoneException({ code: BankErrorCode.OperationDeleted }); } return this.operationView(tx, existing); } const input = { ...normalized }; await this.validateOperation(tx, userId, input); return this.operationView( tx, await tx.bankCardOperation.create({ data: { ...input, id: dto.id, userId, createFingerprint: hash }, }), ); }); } getOperation(userId: string, id: string) { return this.prisma.$transaction(async (tx) => { const row = await this.repository.operation(tx, userId, id); if (row.deletedAt) missing(); return this.operationView(tx, row); }); } updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) { return this.repository.write(async (tx) => { const old = await this.repository.operation(tx, userId, id); if (old.deletedAt) missing(); if (old.version !== dto.version) conflict(); const input = operationInput({ id, kind: dto.kind ?? (old.kind as CreateBankOperationDto['kind']), cardId: dto.cardId ?? old.cardId, targetCardId: dto.targetCardId ?? old.targetCardId ?? undefined, categoryId: dto.categoryId ?? old.categoryId ?? undefined, originalExpenseId: dto.originalExpenseId ?? old.originalExpenseId ?? undefined, amount: dto.amount ?? rubMoney(old.amountKopecks), date: dto.date ?? old.date, 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(); } return this.operationView( tx, await this.repository.operation(tx, userId, id), ); }); } deleteOperation(userId: string, id: string, version: number) { return this.repository.write(async (tx) => { const row = await this.repository.operation(tx, userId, id); if (row.deletedAt) return; if (row.version !== version) conflict(); await this.repository.card(tx, userId, row.cardId, true); if (row.targetCardId) { await this.repository.card(tx, userId, row.targetCardId, true); } if ( row.kind === BankOperationKind.Expense && (await tx.bankCardOperation.findFirst({ where: { userId, originalExpenseId: id, deletedAt: null }, })) ) { conflict(BankErrorCode.DependentRefunds); } if (row.originalExpenseId) { const source = await this.repository.operation( tx, 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(); } }); } history( userId: string, id: string, query: BankOperationsQueryDto | BankPageQueryDto, expenses = false, ) { const period = expenses ? undefined : periodBounds(query as BankOperationsQueryDto); return this.prisma.$transaction(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, }); return { items: await Promise.all( rows.map((row) => this.operationView(tx, row)), ), total, page, hasNext: page * limit < total, }; }); } }