refactor: route bank operation queries through repository (WealthLedger/backend#171)

This commit is contained in:
2026-11-04 22:29:01 +03:00
parent b3d4801cfe
commit cea77f6d56
2 changed files with 131 additions and 140 deletions
@@ -4,7 +4,6 @@ import {
BankCardOperation, BankCardOperation,
Prisma, Prisma,
} from '../../../../../generated/prisma/client'; } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { FIRST_PAGE } from '../domain/bank-card-limits'; import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
import { import {
@@ -32,15 +31,10 @@ import {
periodBounds, periodBounds,
} from './bank-card-input'; } from './bank-card-input';
const EXPECTED_UPDATED_ROWS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankOperationsService { export class BankOperationsService {
constructor( constructor(private readonly repository: BankCardsRepository) {}
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async operationView( private async operationView(
tx: Tx, tx: Tx,
@@ -52,51 +46,7 @@ export class BankOperationsService {
private async pageViews(tx: Tx, rows: BankCardOperation[]) { private async pageViews(tx: Tx, rows: BankCardOperation[]) {
const userId = rows[0]?.userId; const userId = rows[0]?.userId;
if (!userId) return []; if (!userId) return [];
const cardIds = new Set( const { cards, sources, categories, refunds } = await this.repository.operationViewData(tx, userId, rows);
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 cardsById = new Map(cards.map((card) => [card.id, card])); const cardsById = new Map(cards.map((card) => [card.id, card]));
const sourcesById = new Map(sources.map((source) => [source.id, source])); const sourcesById = new Map(sources.map((source) => [source.id, source]));
const categoriesById = new Map(categories.map((category) => [category.id, category])); const categoriesById = new Map(categories.map((category) => [category.id, category]));
@@ -172,16 +122,12 @@ export class BankOperationsService {
if (operationFieldConflict(input)) fail(); if (operationFieldConflict(input)) fail();
if (input.kind === BankOperationKind.Expense) { if (input.kind === BankOperationKind.Expense) {
if (!input.categoryId) { if (!input.categoryId) {
const category = await tx.expenseCategory.findUnique({ const category = await this.repository.defaultCategory(tx, userId);
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
if (!category) missing(); if (!category) missing();
input.categoryId = category.id; input.categoryId = category.id;
} }
if ( if (
!(await tx.expenseCategory.findFirst({ !(await this.repository.category(tx, userId, input.categoryId))
where: { userId, id: input.categoryId },
}))
) { ) {
missing(); missing();
} }
@@ -199,29 +145,11 @@ export class BankOperationsService {
) { ) {
fail(BankErrorCode.InvalidRefund); fail(BankErrorCode.InvalidRefund);
} }
if ( await this.repository.updateOperation(tx,
( { id: source.id, userId, version: source.version, deletedAt: null },
await tx.bankCardOperation.updateMany({ { version: { increment: 1 } },
where: { );
id: source.id, const refunds = await this.repository.refunds(tx, userId, source.id, old?.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 } } : {}),
},
});
if (input.date < source.date) fail(BankErrorCode.InvalidDate); if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if ( if (
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
@@ -232,9 +160,7 @@ export class BankOperationsService {
} }
} }
if (old?.kind === BankOperationKind.Expense) { if (old?.kind === BankOperationKind.Expense) {
const refunds = await tx.bankCardOperation.findMany({ const refunds = await this.repository.refunds(tx, userId, old.id);
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
if ( if (
refunds.length && refunds.length &&
(input.cardId !== old.cardId || (input.cardId !== old.cardId ||
@@ -251,9 +177,7 @@ export class BankOperationsService {
const normalized = operationInput(dto), const normalized = operationInput(dto),
hash = fingerprint(normalized); hash = fingerprint(normalized);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const existing = await tx.bankCardOperation.findUnique({ const existing = await this.repository.operationById(tx, dto.id);
where: { id: dto.id },
});
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); 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. // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
@@ -269,15 +193,15 @@ export class BankOperationsService {
await this.validateOperation(tx, userId, input); await this.validateOperation(tx, userId, input);
return this.operationView( return this.operationView(
tx, tx,
await tx.bankCardOperation.create({ await this.repository.createOperation(tx, {
data: { ...input, id: dto.id, userId, createFingerprint: hash }, ...input, id: dto.id, userId, createFingerprint: hash,
}), }),
); );
}); });
} }
getOperation(userId: string, id: string) { 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); const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) missing(); if (row.deletedAt) missing();
return this.operationView(tx, row); return this.operationView(tx, row);
@@ -302,16 +226,10 @@ export class BankOperationsService {
description: dto.description ?? old.description ?? undefined, description: dto.description ?? old.description ?? undefined,
}); });
await this.validateOperation(tx, userId, input, old); await this.validateOperation(tx, userId, input, old);
if ( await this.repository.updateOperation(tx,
( { id, userId, version: dto.version, deletedAt: null },
await tx.bankCardOperation.updateMany({ { ...input, version: { increment: 1 } },
where: { id, userId, version: dto.version, deletedAt: null }, );
data: { ...input, version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
return this.operationView( return this.operationView(
tx, tx,
await this.repository.operation(tx, userId, id), await this.repository.operation(tx, userId, id),
@@ -330,9 +248,7 @@ export class BankOperationsService {
} }
if ( if (
row.kind === BankOperationKind.Expense && row.kind === BankOperationKind.Expense &&
(await tx.bankCardOperation.findFirst({ (await this.repository.dependentRefund(tx, userId, id))
where: { userId, originalExpenseId: id, deletedAt: null },
}))
) { ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
} }
@@ -342,27 +258,15 @@ export class BankOperationsService {
userId, userId,
row.originalExpenseId, row.originalExpenseId,
); );
if ( await this.repository.updateOperation(tx,
( { id: source.id, userId, version: source.version },
await tx.bankCardOperation.updateMany({ { version: { increment: 1 } },
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, userId, version, deletedAt: null },
{ deletedAt: new Date(), version: { increment: 1 } },
);
}); });
} }
@@ -375,25 +279,13 @@ export class BankOperationsService {
const period = expenses const period = expenses
? undefined ? undefined
: periodBounds(query as BankOperationsQueryDto); : periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => { return this.repository.read(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
page = query.page ?? FIRST_PAGE; page = query.page ?? FIRST_PAGE;
const where: Prisma.BankCardOperationWhereInput = { const { total, rows } = await this.repository.operationPage(
userId, tx, userId, id, period, limit, page,
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 { return {
items: await this.pageViews(tx, rows), items: await this.pageViews(tx, rows),
total, total,
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; 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 { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { import {
@@ -9,11 +9,13 @@ import {
conflict, conflict,
missing, missing,
} from '../application/bank-card-input'; } from '../application/bank-card-input';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
const INITIAL_RETRY_COUNT = 0; const INITIAL_RETRY_COUNT = 0;
const LAST_SEED_OFFSET = 1; const LAST_SEED_OFFSET = 1;
const EXPECTED_UPDATED_CARDS = 1; const EXPECTED_UPDATED_CARDS = 1;
const EXPECTED_UPDATED_OPERATIONS = 1;
const EXPECTED_UPDATED_CATEGORIES = 1; const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 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) { cardById(tx: Tx, id: string) {
return tx.bankCard.findUnique({ where: { id } }); return tx.bankCard.findUnique({ where: { id } });
} }