import { GoneException, Injectable } from '@nestjs/common'; import type { BankCardOperation, Prisma, } from '#generated/prisma/client'; import { FIRST_PAGE } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; import { operationFieldConflict, operationKindChanged, } from '../domain/bank-operation.rules'; 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'; type Tx = Prisma.TransactionClient; @Injectable() export class BankOperationsService { constructor(private readonly repository: BankCardsRepository) {} private async operationView( tx: Tx, row: BankCardOperation, ): Promise { return (await this.pageViews(tx, [row]))[0]; } private async pageViews(tx: Tx, rows: BankCardOperation[]) { const userId = rows[0]?.userId; if (!userId) return []; 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])); return rows.map((row) => { const card = cardsById.get(row.cardId) as { name: string }; const target = row.targetCardId ? cardsById.get(row.targetCardId) : null; const source = row.originalExpenseId ? sourcesById.get(row.originalExpenseId) : null; const categoryId = source?.categoryId ?? row.categoryId; const relatedRefunds = row.kind === BankOperationKind.Expense || source ? refunds.filter( (refund) => refund.originalExpenseId === (source?.id ?? row.id) && (!source || refund.id !== 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: categoriesById.get(categoryId ?? '')?.name ?? null, originalExpenseId: row.originalExpenseId, amount: rubMoney(row.amountKopecks), availableRefund: row.kind === BankOperationKind.Expense || source ? rubMoney( (source?.amountKopecks ?? row.amountKopecks) - relatedRefunds.reduce( (sum, refund) => sum + refund.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 && operationKindChanged(old.kind, input.kind, old.originalExpenseId, input.originalExpenseId)) { fail(BankErrorCode.ImmutableOperation); } if (operationFieldConflict(input)) fail(); if (input.kind === BankOperationKind.Expense) { if (!input.categoryId) { const category = await this.repository.defaultCategory(tx, userId); if (!category) missing(); input.categoryId = category.id; } if ( !(await this.repository.category(tx, userId, input.categoryId)) ) { missing(); } } if (input.kind === BankOperationKind.Refund) { const source = await this.repository.operation( tx, userId, input.originalExpenseId as string, ); if ( source.deletedAt || source.kind !== BankOperationKind.Expense || source.cardId !== input.cardId ) { fail(BankErrorCode.InvalidRefund); } 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) + input.amountKopecks > source.amountKopecks ) { conflict(BankErrorCode.RefundLimit); } } if (old?.kind === BankOperationKind.Expense) { const refunds = await this.repository.refunds(tx, userId, old.id); 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 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. 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 this.repository.createOperation(tx, { ...input, id: dto.id, userId, createFingerprint: hash, }), ); }); } getOperation(userId: string, id: string) { return this.repository.read(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); 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), ); }); } 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 this.repository.dependentRefund(tx, userId, id)) ) { conflict(BankErrorCode.DependentRefunds); } if (row.originalExpenseId) { const source = await this.repository.operation( tx, userId, row.originalExpenseId, ); 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 } }, ); }); } history( userId: string, id: string, query: BankOperationsQueryDto | BankPageQueryDto, expenses = false, ) { const period = expenses ? undefined : periodBounds(query as BankOperationsQueryDto); 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 { total, rows } = await this.repository.operationPage( tx, userId, id, period, limit, page, ); return { items: await this.pageViews(tx, rows), total, page, hasNext: page * limit < total, }; }); } }