From deda7ffc2cc8185eba48a18c37272c404a3e9983 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 4 Nov 2026 21:37:59 +0300 Subject: [PATCH] refactor: single bank operation projection and domain rules (WealthLedger/backend#168) (#189) --- .../application/bank-operations.service.ts | 77 ++-------- .../domain/bank-operation.rules.spec.ts | 141 ++++++++++++++++++ .../bank-cards/domain/bank-operation.rules.ts | 79 ++++++++++ 3 files changed, 229 insertions(+), 68 deletions(-) create mode 100644 src/modules/banking/bank-cards/domain/bank-operation.rules.spec.ts create mode 100644 src/modules/banking/bank-cards/domain/bank-operation.rules.ts diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index e562780..8ffeec3 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -7,6 +7,10 @@ import { import { PrismaService } from '../../../../prisma/prisma.service'; 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 { @@ -42,53 +46,7 @@ export class BankOperationsService { tx: Tx, row: BankCardOperation, ): Promise { - const card = await this.repository.card(tx, row.userId, row.cardId); - const target = row.targetCardId - ? await this.repository.card(tx, row.userId, row.targetCardId) - : null; - const source = row.originalExpenseId - ? await this.repository.operation(tx, row.userId, row.originalExpenseId) - : null; - const categoryId = source?.categoryId ?? row.categoryId; - const category = categoryId - ? await tx.expenseCategory.findFirst({ - where: { id: categoryId, userId: row.userId }, - }) - : null; - const refunds = - row.kind === BankOperationKind.Expense || source - ? await tx.bankCardOperation.findMany({ - where: { - userId: row.userId, - originalExpenseId: source?.id ?? row.id, - deletedAt: null, - ...(source ? { id: { not: 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: category?.name ?? null, - originalExpenseId: row.originalExpenseId, - amount: rubMoney(row.amountKopecks), - availableRefund: - row.kind === BankOperationKind.Expense || source - ? rubMoney( - (source?.amountKopecks ?? row.amountKopecks) - - refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS), - ) - : null, - date: row.date, - description: row.description, - version: row.version, - createdAt: row.createdAt.toISOString(), - }; + return (await this.pageViews(tx, [row]))[0]; } private async pageViews(tx: Tx, rows: BankCardOperation[]) { @@ -207,25 +165,12 @@ export class BankOperationsService { fail(BankErrorCode.InvalidDate); } } - if ( - old && - (old.kind !== input.kind || - old.originalExpenseId !== input.originalExpenseId) - ) { + if (old && operationKindChanged(old.kind, input.kind, + old.originalExpenseId, input.originalExpenseId)) { fail(BankErrorCode.ImmutableOperation); } - if (input.kind === BankOperationKind.Transfer) { - if ( - !input.targetCardId || - input.targetCardId === input.cardId || - input.categoryId || - input.originalExpenseId - ) { - fail(); - } - } else if (input.targetCardId) fail(); + if (operationFieldConflict(input)) fail(); if (input.kind === BankOperationKind.Expense) { - if (input.originalExpenseId) fail(); if (!input.categoryId) { const category = await tx.expenseCategory.findUnique({ where: { userId_seedKey: { userId, seedKey: 'other' } }, @@ -240,16 +185,12 @@ export class BankOperationsService { ) { missing(); } - } else if (input.categoryId) fail(); - if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) { - fail(); } if (input.kind === BankOperationKind.Refund) { - if (!input.originalExpenseId) fail(); const source = await this.repository.operation( tx, userId, - input.originalExpenseId, + input.originalExpenseId as string, ); if ( source.deletedAt || diff --git a/src/modules/banking/bank-cards/domain/bank-operation.rules.spec.ts b/src/modules/banking/bank-cards/domain/bank-operation.rules.spec.ts new file mode 100644 index 0000000..097f830 --- /dev/null +++ b/src/modules/banking/bank-cards/domain/bank-operation.rules.spec.ts @@ -0,0 +1,141 @@ +import { BankOperationKind } from './bank-operation-kind'; +import { + operationFieldConflict, + OperationFieldRule, + operationKindChanged, +} from './bank-operation.rules'; + +const cardId = 'card-1'; + +function fields( + overrides: Partial[0]> = {}, +) { + return { kind: BankOperationKind.Topup, cardId, ...overrides }; +} + +describe('operationFieldConflict', () => { + it('allows a valid topup', () => { + expect(operationFieldConflict(fields())).toBeNull(); + }); + + it('rejects a topup with a purchase link', () => { + expect( + operationFieldConflict( + fields({ originalExpenseId: 'op-1' }), + ), + ).toBe(OperationFieldRule.InvalidCombination); + }); + + it('rejects a non-transfer with a target card', () => { + expect( + operationFieldConflict(fields({ targetCardId: 'card-2' })), + ).toBe(OperationFieldRule.InvalidCombination); + }); + + describe('expense', () => { + it('allows the default category to be resolved by the service', () => { + expect( + operationFieldConflict( + fields({ kind: BankOperationKind.Expense }), + ), + ).toBeNull(); + }); + + it('rejects a purchase link', () => { + expect( + operationFieldConflict( + fields({ + kind: BankOperationKind.Expense, + categoryId: 'cat-1', + originalExpenseId: 'op-1', + }), + ), + ).toBe(OperationFieldRule.InvalidCombination); + }); + + it('allows a plain categorized expense', () => { + expect( + operationFieldConflict( + fields({ kind: BankOperationKind.Expense, categoryId: 'cat-1' }), + ), + ).toBeNull(); + }); + }); + + describe('transfer', () => { + it('requires a target card', () => { + expect( + operationFieldConflict(fields({ kind: BankOperationKind.Transfer })), + ).toBe(OperationFieldRule.MissingTargetCard); + }); + + it('rejects a transfer to the same card', () => { + expect( + operationFieldConflict( + fields({ + kind: BankOperationKind.Transfer, + targetCardId: cardId, + }), + ), + ).toBe(OperationFieldRule.SameTargetCard); + }); + + it('rejects a transfer with a category or purchase link', () => { + expect( + operationFieldConflict( + fields({ + kind: BankOperationKind.Transfer, + targetCardId: 'card-2', + categoryId: 'cat-1', + }), + ), + ).toBe(OperationFieldRule.InvalidCombination); + expect( + operationFieldConflict( + fields({ + kind: BankOperationKind.Transfer, + targetCardId: 'card-2', + originalExpenseId: 'op-1', + }), + ), + ).toBe(OperationFieldRule.InvalidCombination); + }); + }); + + describe('refund', () => { + it('requires a purchase link', () => { + expect( + operationFieldConflict(fields({ kind: BankOperationKind.Refund })), + ).toBe(OperationFieldRule.MissingOriginalExpense); + }); + + it('rejects a category', () => { + expect( + operationFieldConflict( + fields({ + kind: BankOperationKind.Refund, + originalExpenseId: 'op-1', + categoryId: 'cat-1', + }), + ), + ).toBe(OperationFieldRule.InvalidCombination); + }); + + it('allows a linked refund', () => { + expect( + operationFieldConflict( + fields({ kind: BankOperationKind.Refund, originalExpenseId: 'op-1' }), + ), + ).toBeNull(); + }); + }); +}); + +describe('operationKindChanged', () => { + it('flags kind and purchase link changes', () => { + expect(operationKindChanged('expense', 'expense', 'op-1', 'op-1')).toBe(false); + expect(operationKindChanged('expense', 'refund', null, null)).toBe(true); + expect(operationKindChanged('refund', 'refund', 'op-1', null)).toBe(true); + expect(operationKindChanged('refund', 'refund', null, 'op-1')).toBe(true); + }); +}); diff --git a/src/modules/banking/bank-cards/domain/bank-operation.rules.ts b/src/modules/banking/bank-cards/domain/bank-operation.rules.ts new file mode 100644 index 0000000..08011e8 --- /dev/null +++ b/src/modules/banking/bank-cards/domain/bank-operation.rules.ts @@ -0,0 +1,79 @@ +import { BankOperationKind } from './bank-operation-kind'; + +export type OperationFields = { + cardId: string; + categoryId?: null | string; + kind: BankOperationKind; + originalExpenseId?: null | string; + targetCardId?: null | string; +}; + +export enum OperationFieldRule { + InvalidCombination = 'invalidCombination', + MissingOriginalExpense = 'missingOriginalExpense', + MissingTargetCard = 'missingTargetCard', + SameTargetCard = 'sameTargetCard', +} + +function transferConflict(fields: OperationFields): null | OperationFieldRule { + if (!fields.targetCardId) { + return OperationFieldRule.MissingTargetCard; + } + if (fields.targetCardId === fields.cardId) { + return OperationFieldRule.SameTargetCard; + } + if (fields.categoryId || fields.originalExpenseId) { + return OperationFieldRule.InvalidCombination; + } + return null; +} + +function expenseConflict(fields: OperationFields): null | OperationFieldRule { + if (fields.originalExpenseId) { + return OperationFieldRule.InvalidCombination; + } + // Expense without a category uses the user's default category in the service. + return null; +} + +function refundConflict(fields: OperationFields): null | OperationFieldRule { + return fields.originalExpenseId + ? null + : OperationFieldRule.MissingOriginalExpense; +} + +/** + * Pure kind/field compatibility rules for bank operations; no Prisma or Nest. + * Returns the first violated rule, or null when the combination is allowed. + */ +export function operationFieldConflict( + fields: OperationFields, +): null | OperationFieldRule { + if (fields.kind === BankOperationKind.Transfer) { + return transferConflict(fields); + } + if (fields.targetCardId) return OperationFieldRule.InvalidCombination; + if (fields.kind === BankOperationKind.Expense) { + return expenseConflict(fields); + } + if (fields.categoryId) return OperationFieldRule.InvalidCombination; + if (fields.kind === BankOperationKind.Refund) { + return refundConflict(fields); + } + // Topup: no target, no category, no purchase link. + return fields.originalExpenseId + ? OperationFieldRule.InvalidCombination + : null; +} + +export function operationKindChanged( + oldKind: string, + newKind: string, + oldOriginalExpenseId: null | string, + newOriginalExpenseId: null | string, +): boolean { + return ( + oldKind !== newKind || + oldOriginalExpenseId !== newOriginalExpenseId + ); +}