import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { BankCard, 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; const EXPECTED_UPDATED_CARDS = 1; const EXPECTED_UPDATED_CATEGORIES = 1; const VERSION_INCREMENT = 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) {} 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 }], }, }); } 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 { 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, }; }); } }