Files
backend/src/modules/banking/bank-cards/application/bank-operations.service.ts
T
codex-bot 07cfd08df9
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m6s
Backend verify / verify (push) Failing after 6m44s
refactor: replace deep imports with native node aliases (WealthLedger/backend#165) (#193)
2026-11-04 23:22:46 +03:00

299 lines
9.9 KiB
TypeScript

import { GoneException, Injectable } from '@nestjs/common';
import type {
BankCardOperation,
Prisma,
} from '#generated/prisma/client';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
import {
operationFieldConflict,
operationKindChanged,
} from '../domain/bank-operation.rules';
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';
type Tx = Prisma.TransactionClient;
@Injectable()
export class BankOperationsService {
constructor(private readonly repository: BankCardsRepository) {}
private async operationView(
tx: Tx,
row: BankCardOperation,
): Promise<BankOperationDto> {
return (await this.pageViews(tx, [row]))[0];
}
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
const userId = rows[0]?.userId;
if (!userId) return [];
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]));
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 && operationKindChanged(old.kind, input.kind,
old.originalExpenseId, input.originalExpenseId)) {
fail(BankErrorCode.ImmutableOperation);
}
if (operationFieldConflict(input)) fail();
if (input.kind === BankOperationKind.Expense) {
if (!input.categoryId) {
const category = await this.repository.defaultCategory(tx, userId);
if (!category) missing();
input.categoryId = category.id;
}
if (
!(await this.repository.category(tx, userId, input.categoryId))
) {
missing();
}
}
if (input.kind === BankOperationKind.Refund) {
const source = await this.repository.operation(
tx,
userId,
input.originalExpenseId as string,
);
if (
source.deletedAt ||
source.kind !== BankOperationKind.Expense ||
source.cardId !== input.cardId
) {
fail(BankErrorCode.InvalidRefund);
}
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) +
input.amountKopecks >
source.amountKopecks
) {
conflict(BankErrorCode.RefundLimit);
}
}
if (old?.kind === BankOperationKind.Expense) {
const refunds = await this.repository.refunds(tx, userId, old.id);
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 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.
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 this.repository.createOperation(tx, {
...input, id: dto.id, userId, createFingerprint: hash,
}),
);
});
}
getOperation(userId: string, id: string) {
return this.repository.read(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);
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),
);
});
}
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 this.repository.dependentRefund(tx, userId, id))
) {
conflict(BankErrorCode.DependentRefunds);
}
if (row.originalExpenseId) {
const source = await this.repository.operation(
tx,
userId,
row.originalExpenseId,
);
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 } },
);
});
}
history(
userId: string,
id: string,
query: BankOperationsQueryDto | BankPageQueryDto,
expenses = false,
) {
const period = expenses
? undefined
: periodBounds(query as BankOperationsQueryDto);
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 { total, rows } = await this.repository.operationPage(
tx, userId, id, period, limit, page,
);
return {
items: await this.pageViews(tx, rows),
total,
page,
hasNext: page * limit < total,
};
});
}
}