refactor: move bank card queries into repository (WealthLedger/backend#171) #183

Closed
codex-bot wants to merge 1 commits from refactor/171d-bank-card-writes into refactor/171c-bank-card-reads
2 changed files with 51 additions and 24 deletions
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BankCard, Prisma } from '../../../../../generated/prisma/client'; import { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { bankBalance } from '../domain/bank-card.calculations'; import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money'; import { rubMoney } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
@@ -20,15 +19,10 @@ import {
missing, missing,
} from './bank-card-input'; } from './bank-card-input';
const EXPECTED_UPDATED_CARDS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankCardsService { export class BankCardsService {
constructor( constructor(private readonly repository: BankCardsRepository) {}
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> { private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await this.repository.cardOperations(tx, card); const rows = await this.repository.cardOperations(tx, card);
@@ -64,7 +58,7 @@ export class BankCardsService {
const input = cardInput(dto), const input = cardInput(dto),
hash = fingerprint(input); hash = fingerprint(input);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } }); const existing = await this.repository.cardById(tx, dto.id);
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); 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. // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
@@ -77,8 +71,11 @@ export class BankCardsService {
await this.repository.seed(tx, userId); await this.repository.seed(tx, userId);
return this.cardView( return this.cardView(
tx, tx,
await tx.bankCard.create({ await this.repository.createCard(tx, {
data: { ...input, id: dto.id, userId, createFingerprint: hash }, ...input,
id: dto.id,
userId,
createFingerprint: hash,
}), }),
); );
}); });
@@ -111,20 +108,16 @@ export class BankCardsService {
} }
currentDate(input.openingDate); currentDate(input.openingDate);
if ( if (
await tx.bankCardOperation.findFirst({ await this.repository.hasOperationsBefore(tx, userId, id, input.openingDate)
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
) { ) {
fail(BankErrorCode.OpeningDateConflict); fail(BankErrorCode.OpeningDateConflict);
} }
const changed = await tx.bankCard.updateMany({ const updated = await this.repository.updateCard(
where: { id, userId, version: dto.version }, tx,
data: { userId,
id,
dto.version,
{
...input, ...input,
paymentSystem: input.paymentSystem ?? null, paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 }, version: { increment: 1 },
@@ -136,9 +129,8 @@ export class BankCardsService {
: null, : null,
}), }),
}, },
}); );
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict(); return this.cardView(tx, updated);
return this.cardView(tx, await this.repository.card(tx, userId, id));
}); });
} }
} }
@@ -13,6 +13,7 @@ import { BankOperationKind } from '../domain/bank-operation-kind';
const INITIAL_RETRY_COUNT = 0; const INITIAL_RETRY_COUNT = 0;
const LAST_SEED_OFFSET = 1; const LAST_SEED_OFFSET = 1;
const EXPECTED_UPDATED_CARDS = 1;
const EXPECTED_UPDATED_CATEGORIES = 1; const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1; const VERSION_INCREMENT = 1;
@@ -56,6 +57,40 @@ export class BankCardsRepository {
}); });
} }
cardById(tx: Tx, id: string) {
return tx.bankCard.findUnique({ where: { id } });
}
createCard(tx: Tx, data: Prisma.BankCardUncheckedCreateInput) {
return tx.bankCard.create({ data });
}
async updateCard(
tx: Tx,
userId: string,
id: string,
version: number,
data: Prisma.BankCardUpdateManyMutationInput,
) {
const changed = await tx.bankCard.updateMany({
where: { id, userId, version },
data,
});
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.card(tx, userId, id);
}
hasOperationsBefore(tx: Tx, userId: string, id: string, openingDate: string) {
return tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
});
}
async categories(userId: string) { async categories(userId: string) {
return ( return (
await this.prisma.expenseCategory.findMany({ await this.prisma.expenseCategory.findMany({