Keep financial ownership, atomic publication and refresh CAS. Failed refresh leaves cookies untouched; logout still clears them. Remove full-history candle prefetch and special metadata retries. Use native Pino request context instead of parallel middleware. BREAKING CHANGE: remove investment stateVersion, queued sync state, and tbank-sync status progress/resources. Consumers must observe resources through sync=observe with the updated frontend contract.
126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
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<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
|
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,
|
|
};
|
|
});
|
|
}
|
|
}
|