chore(release): 2.4.1 #199
@@ -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<BankOperationDto> {
|
||||
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 ||
|
||||
|
||||
@@ -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