465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
import { GoneException, Injectable } from '@nestjs/common';
|
|
|
|
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';
|
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
|
import {
|
|
BankOperationDto,
|
|
BankOperationsQueryDto,
|
|
BankPageQueryDto,
|
|
CreateBankOperationDto,
|
|
DEFAULT_BANK_PAGE_SIZE,
|
|
UpdateBankOperationDto,
|
|
} from '../presentation/bank-card.dto';
|
|
import {
|
|
BankErrorCode,
|
|
conflict,
|
|
currentDate,
|
|
fail,
|
|
fingerprint,
|
|
missing,
|
|
operationInput,
|
|
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,
|
|
) {}
|
|
|
|
private async operationView(
|
|
tx: Tx,
|
|
row: BankCardOperation,
|
|
): Promise<BankOperationDto> {
|
|
const card = await this.repository.card(tx, row.userId, row.cardId);
|
|
const target = row.targetCardId
|
|
? await this.repository.card(tx, row.userId, row.targetCardId)
|
|
: null;
|
|
const source = row.originalExpenseId
|
|
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
|
|
: null;
|
|
const categoryId = source?.categoryId ?? row.categoryId;
|
|
const category = categoryId
|
|
? await tx.expenseCategory.findFirst({
|
|
where: { id: categoryId, userId: row.userId },
|
|
})
|
|
: 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 } } : {}),
|
|
},
|
|
})
|
|
: [];
|
|
return {
|
|
id: row.id,
|
|
kind: row.kind as BankOperationDto['kind'],
|
|
cardId: row.cardId,
|
|
cardName: card.name,
|
|
targetCardId: row.targetCardId,
|
|
targetCardName: target?.name ?? null,
|
|
categoryId,
|
|
categoryName: category?.name ?? null,
|
|
originalExpenseId: row.originalExpenseId,
|
|
amount: rubMoney(row.amountKopecks),
|
|
availableRefund:
|
|
row.kind === BankOperationKind.Expense || source
|
|
? rubMoney(
|
|
(source?.amountKopecks ?? row.amountKopecks) -
|
|
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
|
|
)
|
|
: null,
|
|
date: row.date,
|
|
description: row.description,
|
|
version: row.version,
|
|
createdAt: row.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
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 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]));
|
|
return rows.map((row) => {
|
|
const card = cardsById.get(row.cardId) as { name: string };
|
|
const target = row.targetCardId ? cardsById.get(row.targetCardId) : null;
|
|
const source = row.originalExpenseId
|
|
? sourcesById.get(row.originalExpenseId)
|
|
: null;
|
|
const categoryId = source?.categoryId ?? row.categoryId;
|
|
const relatedRefunds =
|
|
row.kind === BankOperationKind.Expense || source
|
|
? refunds.filter(
|
|
(refund) =>
|
|
refund.originalExpenseId === (source?.id ?? row.id) &&
|
|
(!source || refund.id !== row.id),
|
|
)
|
|
: [];
|
|
return {
|
|
id: row.id,
|
|
kind: row.kind as BankOperationDto['kind'],
|
|
cardId: row.cardId,
|
|
cardName: card.name,
|
|
targetCardId: row.targetCardId,
|
|
targetCardName: target?.name ?? null,
|
|
categoryId,
|
|
categoryName: categoriesById.get(categoryId ?? '')?.name ?? null,
|
|
originalExpenseId: row.originalExpenseId,
|
|
amount: rubMoney(row.amountKopecks),
|
|
availableRefund:
|
|
row.kind === BankOperationKind.Expense || source
|
|
? rubMoney(
|
|
(source?.amountKopecks ?? row.amountKopecks) -
|
|
relatedRefunds.reduce(
|
|
(sum, refund) => sum + refund.amountKopecks,
|
|
ZERO_KOPECKS,
|
|
),
|
|
)
|
|
: null,
|
|
date: row.date,
|
|
description: row.description,
|
|
version: row.version,
|
|
createdAt: row.createdAt.toISOString(),
|
|
};
|
|
});
|
|
}
|
|
|
|
private async validateOperation(
|
|
tx: Tx,
|
|
userId: string,
|
|
input: ReturnType<typeof operationInput>,
|
|
old?: BankCardOperation,
|
|
) {
|
|
const cards = new Set(
|
|
[input.cardId, input.targetCardId, old?.cardId, old?.targetCardId].filter(
|
|
(id): id is string => Boolean(id),
|
|
),
|
|
);
|
|
currentDate(input.date);
|
|
for (const id of cards) {
|
|
const card = await this.repository.card(tx, userId, id, true);
|
|
if (
|
|
(id === input.cardId || id === input.targetCardId) &&
|
|
input.date < card.openingDate
|
|
) {
|
|
fail(BankErrorCode.InvalidDate);
|
|
}
|
|
}
|
|
if (
|
|
old &&
|
|
(old.kind !== input.kind ||
|
|
old.originalExpenseId !== input.originalExpenseId)
|
|
) {
|
|
fail(BankErrorCode.ImmutableOperation);
|
|
}
|
|
if (input.kind === BankOperationKind.Transfer) {
|
|
if (
|
|
!input.targetCardId ||
|
|
input.targetCardId === input.cardId ||
|
|
input.categoryId ||
|
|
input.originalExpenseId
|
|
) {
|
|
fail();
|
|
}
|
|
} else if (input.targetCardId) fail();
|
|
if (input.kind === BankOperationKind.Expense) {
|
|
if (input.originalExpenseId) fail();
|
|
if (!input.categoryId) {
|
|
const category = await tx.expenseCategory.findUnique({
|
|
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
|
});
|
|
if (!category) missing();
|
|
input.categoryId = category.id;
|
|
}
|
|
if (
|
|
!(await tx.expenseCategory.findFirst({
|
|
where: { userId, id: input.categoryId },
|
|
}))
|
|
) {
|
|
missing();
|
|
}
|
|
} else if (input.categoryId) fail();
|
|
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
|
|
fail();
|
|
}
|
|
if (input.kind === BankOperationKind.Refund) {
|
|
if (!input.originalExpenseId) fail();
|
|
const source = await this.repository.operation(
|
|
tx,
|
|
userId,
|
|
input.originalExpenseId,
|
|
);
|
|
if (
|
|
source.deletedAt ||
|
|
source.kind !== BankOperationKind.Expense ||
|
|
source.cardId !== input.cardId
|
|
) {
|
|
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 } } : {}),
|
|
},
|
|
});
|
|
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
|
if (
|
|
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
|
|
input.amountKopecks >
|
|
source.amountKopecks
|
|
) {
|
|
conflict(BankErrorCode.RefundLimit);
|
|
}
|
|
}
|
|
if (old?.kind === BankOperationKind.Expense) {
|
|
const refunds = await tx.bankCardOperation.findMany({
|
|
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
|
});
|
|
if (
|
|
refunds.length &&
|
|
(input.cardId !== old.cardId ||
|
|
refunds.some((row) => row.date < input.date) ||
|
|
refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) >
|
|
input.amountKopecks)
|
|
) {
|
|
conflict(BankErrorCode.DependentRefunds);
|
|
}
|
|
}
|
|
}
|
|
|
|
createOperation(userId: string, dto: CreateBankOperationDto) {
|
|
const normalized = operationInput(dto),
|
|
hash = fingerprint(normalized);
|
|
return this.repository.write(async (tx) => {
|
|
const existing = await tx.bankCardOperation.findUnique({
|
|
where: { id: 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.
|
|
if (existing.createFingerprint !== hash) {
|
|
conflict(BankErrorCode.IntentConflict);
|
|
}
|
|
if (existing.deletedAt) {
|
|
throw new GoneException({ code: BankErrorCode.OperationDeleted });
|
|
}
|
|
return this.operationView(tx, existing);
|
|
}
|
|
const input = { ...normalized };
|
|
await this.validateOperation(tx, userId, input);
|
|
return this.operationView(
|
|
tx,
|
|
await tx.bankCardOperation.create({
|
|
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
getOperation(userId: string, id: string) {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const row = await this.repository.operation(tx, userId, id);
|
|
if (row.deletedAt) missing();
|
|
return this.operationView(tx, row);
|
|
});
|
|
}
|
|
|
|
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
|
|
return this.repository.write(async (tx) => {
|
|
const old = await this.repository.operation(tx, userId, id);
|
|
if (old.deletedAt) missing();
|
|
if (old.version !== dto.version) conflict();
|
|
const input = operationInput({
|
|
id,
|
|
kind: dto.kind ?? (old.kind as CreateBankOperationDto['kind']),
|
|
cardId: dto.cardId ?? old.cardId,
|
|
targetCardId: dto.targetCardId ?? old.targetCardId ?? undefined,
|
|
categoryId: dto.categoryId ?? old.categoryId ?? undefined,
|
|
originalExpenseId:
|
|
dto.originalExpenseId ?? old.originalExpenseId ?? undefined,
|
|
amount: dto.amount ?? rubMoney(old.amountKopecks),
|
|
date: dto.date ?? old.date,
|
|
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();
|
|
}
|
|
return this.operationView(
|
|
tx,
|
|
await this.repository.operation(tx, userId, id),
|
|
);
|
|
});
|
|
}
|
|
|
|
deleteOperation(userId: string, id: string, version: number) {
|
|
return this.repository.write(async (tx) => {
|
|
const row = await this.repository.operation(tx, userId, id);
|
|
if (row.deletedAt) return;
|
|
if (row.version !== version) conflict();
|
|
await this.repository.card(tx, userId, row.cardId, true);
|
|
if (row.targetCardId) {
|
|
await this.repository.card(tx, userId, row.targetCardId, true);
|
|
}
|
|
if (
|
|
row.kind === BankOperationKind.Expense &&
|
|
(await tx.bankCardOperation.findFirst({
|
|
where: { userId, originalExpenseId: id, deletedAt: null },
|
|
}))
|
|
) {
|
|
conflict(BankErrorCode.DependentRefunds);
|
|
}
|
|
if (row.originalExpenseId) {
|
|
const source = await this.repository.operation(
|
|
tx,
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
|
|
history(
|
|
userId: string,
|
|
id: string,
|
|
query: BankOperationsQueryDto | BankPageQueryDto,
|
|
expenses = false,
|
|
) {
|
|
const period = expenses
|
|
? undefined
|
|
: periodBounds(query as BankOperationsQueryDto);
|
|
return this.prisma.$transaction(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,
|
|
});
|
|
return {
|
|
items: await this.pageViews(tx, rows),
|
|
total,
|
|
page,
|
|
hasNext: page * limit < total,
|
|
};
|
|
});
|
|
}
|
|
}
|