Files
backend/src/modules/banking/bank-cards/application/bank-cards.service.ts
T

156 lines
5.0 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import {
BankCardDto,
CreateBankCardDto,
UpdateBankCardDto,
} from '../presentation/bank-card.dto';
import {
BankErrorCode,
cardInput,
conflict,
currentDate,
fail,
fingerprint,
missing,
} from './bank-card-input';
const EXPECTED_UPDATED_CARDS = 1;
type Tx = Prisma.TransactionClient;
@Injectable()
export class BankCardsService {
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await tx.bankCardOperation.findMany({
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
return {
id: card.id,
name: card.name,
bankName: card.bankName,
lastFour: card.lastFour,
paymentSystem: card.paymentSystem,
openingDate: card.openingDate,
openingBalance: rubMoney(card.openingBalanceKopecks),
balance: rubMoney(bankBalance(card.id, card.openingBalanceKopecks, rows)),
version: card.version,
archivedAt: card.archivedAt?.toISOString() ?? null,
};
}
list(userId: string, includeArchived = false) {
return this.prisma.$transaction(async (tx) =>
Promise.all(
(
await tx.bankCard.findMany({
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
).map((card) => this.cardView(tx, card)),
),
);
}
get(userId: string, id: string) {
return this.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.repository.card(tx, userId, id)),
);
}
createCard(userId: string, dto: CreateBankCardDto) {
const input = cardInput(dto),
hash = fingerprint(input);
return this.repository.write(async (tx) => {
const existing = await tx.bankCard.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);
}
return this.cardView(tx, existing);
}
currentDate(input.openingDate);
await this.repository.seed(tx, userId);
return this.cardView(
tx,
await tx.bankCard.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
return this.repository.write(async (tx) => {
const previous = await this.repository.card(tx, userId, id);
if (previous.version !== dto.version) conflict();
const input = cardInput({
id,
name: dto.name ?? previous.name,
bankName:
dto.bankName === undefined ? previous.bankName : dto.bankName,
lastFour:
dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
paymentSystem:
dto.paymentSystem === undefined
? previous.paymentSystem
: dto.paymentSystem,
openingBalance:
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
openingDate: dto.openingDate ?? previous.openingDate,
});
const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt) {
conflict(BankErrorCode.CardArchived);
}
currentDate(input.openingDate);
if (
await tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
) {
fail(BankErrorCode.OpeningDateConflict);
}
const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version },
data: {
...input,
paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 },
...(dto.archived === undefined
? {}
: {
archivedAt: dto.archived
? (previous.archivedAt ?? new Date())
: null,
}),
},
});
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.cardView(tx, await this.repository.card(tx, userId, id));
});
}
}