refactor: single bank operation projection and domain rules (WealthLedger/backend#168)
This commit is contained in:
@@ -7,6 +7,10 @@ import {
|
|||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
|
import {
|
||||||
|
operationFieldConflict,
|
||||||
|
operationKindChanged,
|
||||||
|
} from '../domain/bank-operation.rules';
|
||||||
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
import {
|
import {
|
||||||
@@ -42,53 +46,7 @@ export class BankOperationsService {
|
|||||||
tx: Tx,
|
tx: Tx,
|
||||||
row: BankCardOperation,
|
row: BankCardOperation,
|
||||||
): Promise<BankOperationDto> {
|
): Promise<BankOperationDto> {
|
||||||
const card = await this.repository.card(tx, row.userId, row.cardId);
|
return (await this.pageViews(tx, [row]))[0];
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
||||||
@@ -207,25 +165,12 @@ export class BankOperationsService {
|
|||||||
fail(BankErrorCode.InvalidDate);
|
fail(BankErrorCode.InvalidDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (old && operationKindChanged(old.kind, input.kind,
|
||||||
old &&
|
old.originalExpenseId, input.originalExpenseId)) {
|
||||||
(old.kind !== input.kind ||
|
|
||||||
old.originalExpenseId !== input.originalExpenseId)
|
|
||||||
) {
|
|
||||||
fail(BankErrorCode.ImmutableOperation);
|
fail(BankErrorCode.ImmutableOperation);
|
||||||
}
|
}
|
||||||
if (input.kind === BankOperationKind.Transfer) {
|
if (operationFieldConflict(input)) fail();
|
||||||
if (
|
|
||||||
!input.targetCardId ||
|
|
||||||
input.targetCardId === input.cardId ||
|
|
||||||
input.categoryId ||
|
|
||||||
input.originalExpenseId
|
|
||||||
) {
|
|
||||||
fail();
|
|
||||||
}
|
|
||||||
} else if (input.targetCardId) fail();
|
|
||||||
if (input.kind === BankOperationKind.Expense) {
|
if (input.kind === BankOperationKind.Expense) {
|
||||||
if (input.originalExpenseId) fail();
|
|
||||||
if (!input.categoryId) {
|
if (!input.categoryId) {
|
||||||
const category = await tx.expenseCategory.findUnique({
|
const category = await tx.expenseCategory.findUnique({
|
||||||
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
||||||
@@ -240,16 +185,12 @@ export class BankOperationsService {
|
|||||||
) {
|
) {
|
||||||
missing();
|
missing();
|
||||||
}
|
}
|
||||||
} else if (input.categoryId) fail();
|
|
||||||
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
|
|
||||||
fail();
|
|
||||||
}
|
}
|
||||||
if (input.kind === BankOperationKind.Refund) {
|
if (input.kind === BankOperationKind.Refund) {
|
||||||
if (!input.originalExpenseId) fail();
|
|
||||||
const source = await this.repository.operation(
|
const source = await this.repository.operation(
|
||||||
tx,
|
tx,
|
||||||
userId,
|
userId,
|
||||||
input.originalExpenseId,
|
input.originalExpenseId as string,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
source.deletedAt ||
|
source.deletedAt ||
|
||||||
|
|||||||
@@ -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<Parameters<typeof operationFieldConflict>[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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user