156 lines
5.0 KiB
TypeScript
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));
|
|
});
|
|
}
|
|
}
|