246 lines
6.8 KiB
TypeScript
246 lines
6.8 KiB
TypeScript
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<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
|
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<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,
|
|
};
|
|
});
|
|
}
|
|
}
|