import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { Prisma } from '../../../../../generated/prisma/client'; import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code'; import { PrismaService } from '../../../../prisma/prisma.service'; import { BankErrorCode, conflict, missing, } from '../application/bank-card-input'; import { BankOperationKind } from '../domain/bank-operation-kind'; const INITIAL_RETRY_COUNT = 0; const LAST_SEED_OFFSET = 1; type Tx = Prisma.TransactionClient; const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_TIMEOUT_MS = 15_000; const MAX_WRITE_RETRY_COUNT = 2; const seeds = [ 'Продукты', 'Кафе', 'Такси', 'Транспорт', 'Жильё', 'Здоровье', 'Покупки', 'Развлечения', 'Другое', ]; @Injectable() export class BankCardsRepository { constructor(private readonly prisma: PrismaService) {} async write(run: (tx: Tx) => Promise): Promise { for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) { try { return await this.prisma.$transaction(run, { maxWait: TRANSACTION_MAX_WAIT_MS, timeout: TRANSACTION_TIMEOUT_MS, }); } catch (error) { const code = error instanceof Prisma.PrismaClientKnownRequestError ? error.code : ''; const busy = isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) || isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) || (error instanceof Error && /SQLITE_BUSY|database is locked/.test(error.message)); if (busy && attempt < MAX_WRITE_RETRY_COUNT) { continue; } if (busy) conflict(BankErrorCode.WriteConflict); if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory); throw error; } } } async card(tx: Tx, userId: string, id: string, active = false) { const card = await tx.bankCard.findFirst({ where: { id, userId } }); if (!card) return missing(); if (active && card.archivedAt) conflict(BankErrorCode.CardArchived); return card; } async operation(tx: Tx, userId: string, id: string) { const row = await tx.bankCardOperation.findFirst({ where: { id, userId } }); if (!row) return missing(); return row; } async seed(tx: Tx, userId: string) { for (const [index, label] of seeds.entries()) { const seedKey = index === seeds.length - LAST_SEED_OFFSET ? 'other' : String(index); if ( !(await tx.expenseCategory.findUnique({ where: { userId_seedKey: { userId, seedKey } }, })) ) { await tx.expenseCategory.create({ data: { id: randomUUID(), userId, seedKey, name: label, nameKey: label.toLocaleLowerCase('ru-RU'), }, }); } } } summaryData( userId: string, period: { from: string; to: string }, cardId?: string, ) { return this.prisma.$transaction(async (tx) => { if (cardId) await this.card(tx, userId, cardId); const rows = await tx.bankCardOperation.findMany({ where: { userId, deletedAt: null, date: { gte: period.from, lte: period.to }, kind: { in: [BankOperationKind.Expense, BankOperationKind.Refund] }, ...(cardId ? { cardId } : {}), }, include: { originalExpense: { select: { categoryId: true } } }, }); const categories = await tx.expenseCategory.findMany({ where: { userId }, }); return { operations: rows.map((row) => ({ ...row, categoryId: row.originalExpense?.categoryId ?? row.categoryId, })), categories, }; }); } }