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 { 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)); }); } }