refactor: route bank operation queries through repository (WealthLedger/backend#171) #184
@@ -4,7 +4,6 @@ import {
|
||||
BankCardOperation,
|
||||
Prisma,
|
||||
} from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
||||
@@ -28,15 +27,10 @@ import {
|
||||
periodBounds,
|
||||
} from './bank-card-input';
|
||||
|
||||
const EXPECTED_UPDATED_ROWS = 1;
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
@Injectable()
|
||||
export class BankOperationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: BankCardsRepository,
|
||||
) {}
|
||||
constructor(private readonly repository: BankCardsRepository) {}
|
||||
|
||||
private async operationView(
|
||||
tx: Tx,
|
||||
@@ -51,21 +45,14 @@ export class BankOperationsService {
|
||||
: null;
|
||||
const categoryId = source?.categoryId ?? row.categoryId;
|
||||
const category = categoryId
|
||||
? await tx.expenseCategory.findFirst({
|
||||
where: { id: categoryId, userId: row.userId },
|
||||
})
|
||||
? await this.repository.category(tx, row.userId, categoryId)
|
||||
: null;
|
||||
const refunds =
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId: row.userId,
|
||||
originalExpenseId: source?.id ?? row.id,
|
||||
deletedAt: null,
|
||||
...(source ? { id: { not: row.id } } : {}),
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const refunds = [] as Awaited<ReturnType<BankCardsRepository['refunds']>>;
|
||||
if (row.kind === BankOperationKind.Expense || source) {
|
||||
refunds.push(...await this.repository.refunds(
|
||||
tx, row.userId, source?.id ?? row.id, source?.id ? row.id : undefined,
|
||||
));
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as BankOperationDto['kind'],
|
||||
@@ -94,51 +81,7 @@ export class BankOperationsService {
|
||||
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
||||
const userId = rows[0]?.userId;
|
||||
if (!userId) return [];
|
||||
const cardIds = new Set(
|
||||
rows.flatMap((row) =>
|
||||
[row.cardId, row.targetCardId].filter(
|
||||
(id): id is string => id !== null,
|
||||
),
|
||||
),
|
||||
);
|
||||
const sourceIds = new Set(
|
||||
rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])),
|
||||
);
|
||||
const [cards, sources] = await Promise.all([
|
||||
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
|
||||
tx.bankCardOperation.findMany({
|
||||
where: { userId, id: { in: [...sourceIds] } },
|
||||
}),
|
||||
]);
|
||||
const categoryIds = new Set(
|
||||
rows
|
||||
.map(
|
||||
(row) =>
|
||||
sources.find((source) => source.id === row.originalExpenseId)
|
||||
?.categoryId ?? row.categoryId,
|
||||
)
|
||||
.filter((id): id is string => id !== null),
|
||||
);
|
||||
const expenseIds = new Set(
|
||||
rows.flatMap((row) => {
|
||||
const source = sources.find((item) => item.id === row.originalExpenseId);
|
||||
return row.kind === BankOperationKind.Expense || source
|
||||
? [source?.id ?? row.id]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
const [categories, refunds] = await Promise.all([
|
||||
tx.expenseCategory.findMany({
|
||||
where: { userId, id: { in: [...categoryIds] } },
|
||||
}),
|
||||
tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
originalExpenseId: { in: [...expenseIds] },
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const { cards, sources, categories, refunds } = await this.repository.operationViewData(tx, userId, rows);
|
||||
const cardsById = new Map(cards.map((card) => [card.id, card]));
|
||||
const sourcesById = new Map(sources.map((source) => [source.id, source]));
|
||||
const categoriesById = new Map(categories.map((category) => [category.id, category]));
|
||||
@@ -227,16 +170,12 @@ export class BankOperationsService {
|
||||
if (input.kind === BankOperationKind.Expense) {
|
||||
if (input.originalExpenseId) fail();
|
||||
if (!input.categoryId) {
|
||||
const category = await tx.expenseCategory.findUnique({
|
||||
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
||||
});
|
||||
const category = await this.repository.defaultCategory(tx, userId);
|
||||
if (!category) missing();
|
||||
input.categoryId = category.id;
|
||||
}
|
||||
if (
|
||||
!(await tx.expenseCategory.findFirst({
|
||||
where: { userId, id: input.categoryId },
|
||||
}))
|
||||
!(await this.repository.category(tx, userId, input.categoryId))
|
||||
) {
|
||||
missing();
|
||||
}
|
||||
@@ -258,29 +197,11 @@ export class BankOperationsService {
|
||||
) {
|
||||
fail(BankErrorCode.InvalidRefund);
|
||||
}
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: {
|
||||
id: source.id,
|
||||
userId,
|
||||
version: source.version,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { version: { increment: 1 } },
|
||||
})
|
||||
).count !== EXPECTED_UPDATED_ROWS
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
const refunds = await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
originalExpenseId: source.id,
|
||||
deletedAt: null,
|
||||
...(old ? { id: { not: old.id } } : {}),
|
||||
},
|
||||
});
|
||||
await this.repository.updateOperation(tx,
|
||||
{ id: source.id, userId, version: source.version, deletedAt: null },
|
||||
{ version: { increment: 1 } },
|
||||
);
|
||||
const refunds = await this.repository.refunds(tx, userId, source.id, old?.id);
|
||||
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
||||
if (
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
|
||||
@@ -291,9 +212,7 @@ export class BankOperationsService {
|
||||
}
|
||||
}
|
||||
if (old?.kind === BankOperationKind.Expense) {
|
||||
const refunds = await tx.bankCardOperation.findMany({
|
||||
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
||||
});
|
||||
const refunds = await this.repository.refunds(tx, userId, old.id);
|
||||
if (
|
||||
refunds.length &&
|
||||
(input.cardId !== old.cardId ||
|
||||
@@ -310,9 +229,7 @@ export class BankOperationsService {
|
||||
const normalized = operationInput(dto),
|
||||
hash = fingerprint(normalized);
|
||||
return this.repository.write(async (tx) => {
|
||||
const existing = await tx.bankCardOperation.findUnique({
|
||||
where: { id: dto.id },
|
||||
});
|
||||
const existing = await this.repository.operationById(tx, dto.id);
|
||||
if (existing) {
|
||||
if (existing.userId !== userId) missing();
|
||||
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
||||
@@ -328,15 +245,15 @@ export class BankOperationsService {
|
||||
await this.validateOperation(tx, userId, input);
|
||||
return this.operationView(
|
||||
tx,
|
||||
await tx.bankCardOperation.create({
|
||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
||||
await this.repository.createOperation(tx, {
|
||||
...input, id: dto.id, userId, createFingerprint: hash,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getOperation(userId: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
return this.repository.read(async (tx) => {
|
||||
const row = await this.repository.operation(tx, userId, id);
|
||||
if (row.deletedAt) missing();
|
||||
return this.operationView(tx, row);
|
||||
@@ -361,16 +278,10 @@ export class BankOperationsService {
|
||||
description: dto.description ?? old.description ?? undefined,
|
||||
});
|
||||
await this.validateOperation(tx, userId, input, old);
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: { id, userId, version: dto.version, deletedAt: null },
|
||||
data: { ...input, version: { increment: 1 } },
|
||||
})
|
||||
).count !== EXPECTED_UPDATED_ROWS
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
await this.repository.updateOperation(tx,
|
||||
{ id, userId, version: dto.version, deletedAt: null },
|
||||
{ ...input, version: { increment: 1 } },
|
||||
);
|
||||
return this.operationView(
|
||||
tx,
|
||||
await this.repository.operation(tx, userId, id),
|
||||
@@ -389,9 +300,7 @@ export class BankOperationsService {
|
||||
}
|
||||
if (
|
||||
row.kind === BankOperationKind.Expense &&
|
||||
(await tx.bankCardOperation.findFirst({
|
||||
where: { userId, originalExpenseId: id, deletedAt: null },
|
||||
}))
|
||||
(await this.repository.dependentRefund(tx, userId, id))
|
||||
) {
|
||||
conflict(BankErrorCode.DependentRefunds);
|
||||
}
|
||||
@@ -401,27 +310,15 @@ export class BankOperationsService {
|
||||
userId,
|
||||
row.originalExpenseId,
|
||||
);
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: { id: source.id, userId, version: source.version },
|
||||
data: { version: { increment: 1 } },
|
||||
})
|
||||
).count !== EXPECTED_UPDATED_ROWS
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
}
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: { id, userId, version, deletedAt: null },
|
||||
data: { deletedAt: new Date(), version: { increment: 1 } },
|
||||
})
|
||||
).count !== EXPECTED_UPDATED_ROWS
|
||||
) {
|
||||
conflict();
|
||||
await this.repository.updateOperation(tx,
|
||||
{ id: source.id, userId, version: source.version },
|
||||
{ version: { increment: 1 } },
|
||||
);
|
||||
}
|
||||
await this.repository.updateOperation(tx,
|
||||
{ id, userId, version, deletedAt: null },
|
||||
{ deletedAt: new Date(), version: { increment: 1 } },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -434,25 +331,13 @@ export class BankOperationsService {
|
||||
const period = expenses
|
||||
? undefined
|
||||
: periodBounds(query as BankOperationsQueryDto);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
return this.repository.read(async (tx) => {
|
||||
await this.repository.card(tx, userId, id);
|
||||
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
||||
page = query.page ?? FIRST_PAGE;
|
||||
const where: Prisma.BankCardOperationWhereInput = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
OR: [{ cardId: id }, { targetCardId: id }],
|
||||
...(period
|
||||
? { date: { gte: period.from, lte: period.to } }
|
||||
: { kind: BankOperationKind.Expense }),
|
||||
};
|
||||
const total = await tx.bankCardOperation.count({ where });
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
where,
|
||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - FIRST_PAGE) * limit,
|
||||
take: limit,
|
||||
});
|
||||
const { total, rows } = await this.repository.operationPage(
|
||||
tx, userId, id, period, limit, page,
|
||||
);
|
||||
return {
|
||||
items: await this.pageViews(tx, rows),
|
||||
total,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
||||
import { BankCard, BankCardOperation, Prisma } from '../../../../../generated/prisma/client';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import {
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
conflict,
|
||||
missing,
|
||||
} from '../application/bank-card-input';
|
||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||
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_OPERATIONS = 1;
|
||||
const EXPECTED_UPDATED_CATEGORIES = 1;
|
||||
const VERSION_INCREMENT = 1;
|
||||
|
||||
@@ -57,6 +59,103 @@ export class BankCardsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
operationById(tx: Tx, id: string) {
|
||||
return tx.bankCardOperation.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
createOperation(tx: Tx, data: Prisma.BankCardOperationUncheckedCreateInput) {
|
||||
return tx.bankCardOperation.create({ data });
|
||||
}
|
||||
|
||||
async updateOperation(
|
||||
tx: Tx,
|
||||
where: Prisma.BankCardOperationWhereInput,
|
||||
data: Prisma.BankCardOperationUpdateManyMutationInput,
|
||||
) {
|
||||
const result = await tx.bankCardOperation.updateMany({ where, data });
|
||||
if (result.count !== EXPECTED_UPDATED_OPERATIONS) conflict();
|
||||
}
|
||||
|
||||
category(tx: Tx, userId: string, id: string) {
|
||||
return tx.expenseCategory.findFirst({ where: { id, userId } });
|
||||
}
|
||||
|
||||
defaultCategory(tx: Tx, userId: string) {
|
||||
return tx.expenseCategory.findUnique({
|
||||
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
||||
});
|
||||
}
|
||||
|
||||
refunds(tx: Tx, userId: string, originalExpenseId: string, excludeId?: string) {
|
||||
return tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
originalExpenseId,
|
||||
deletedAt: null,
|
||||
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dependentRefund(tx: Tx, userId: string, originalExpenseId: string) {
|
||||
return tx.bankCardOperation.findFirst({
|
||||
where: { userId, originalExpenseId, deletedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
async operationViewData(tx: Tx, userId: string, rows: BankCardOperation[]) {
|
||||
const cardIds = new Set(rows.flatMap((row) =>
|
||||
[row.cardId, row.targetCardId].filter((id): id is string => id !== null),
|
||||
));
|
||||
const sourceIds = new Set(rows.flatMap((row) =>
|
||||
row.originalExpenseId ? [row.originalExpenseId] : [],
|
||||
));
|
||||
const [cards, sources] = await Promise.all([
|
||||
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
|
||||
tx.bankCardOperation.findMany({ where: { userId, id: { in: [...sourceIds] } } }),
|
||||
]);
|
||||
const categoryIds = new Set(rows.map((row) =>
|
||||
sources.find((source) => source.id === row.originalExpenseId)?.categoryId ?? row.categoryId,
|
||||
).filter((id): id is string => id !== null));
|
||||
const expenseIds = new Set(rows.flatMap((row) => {
|
||||
const source = sources.find((item) => item.id === row.originalExpenseId);
|
||||
return row.kind === BankOperationKind.Expense || source ? [source?.id ?? row.id] : [];
|
||||
}));
|
||||
const [categories, refunds] = await Promise.all([
|
||||
tx.expenseCategory.findMany({ where: { userId, id: { in: [...categoryIds] } } }),
|
||||
tx.bankCardOperation.findMany({
|
||||
where: { userId, originalExpenseId: { in: [...expenseIds] }, deletedAt: null },
|
||||
}),
|
||||
]);
|
||||
return { cards, sources, categories, refunds };
|
||||
}
|
||||
|
||||
async operationPage(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
id: string,
|
||||
period: undefined | { from: string; to: string },
|
||||
limit: number,
|
||||
page: number,
|
||||
) {
|
||||
const where: Prisma.BankCardOperationWhereInput = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
OR: [{ cardId: id }, { targetCardId: id }],
|
||||
...(period
|
||||
? { date: { gte: period.from, lte: period.to } }
|
||||
: { kind: BankOperationKind.Expense }),
|
||||
};
|
||||
const total = await tx.bankCardOperation.count({ where });
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
where,
|
||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - FIRST_PAGE) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return { total, rows };
|
||||
}
|
||||
|
||||
cardById(tx: Tx, id: string) {
|
||||
return tx.bankCard.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user