Files
backend/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts
T
ksv741 d698ed9425
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 9m36s
refactor!: simplify runtime coordination #7
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.
2026-10-03 11:38:08 +03:00

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,
};
});
}
}