feat: add manual bank card ledger
Store exact RUB balances, atomic transfers, linked refunds and versioned edits independently of T-Bank. Add migration, HTTP and durability verification.
This commit is contained in:
@@ -31,7 +31,7 @@ lock во время retry внешней сети. Access/cache generation sema
|
|||||||
не JS floating-point арифметику для сумм. Currency — отдельная часть Money;
|
не JS floating-point арифметику для сумм. Currency — отдельная часть Money;
|
||||||
разные валюты не складываются без явно заданного conversion contract.
|
разные валюты не складываются без явно заданного conversion contract.
|
||||||
Источник: [Money](../../../docs/glossary.md#money),
|
Источник: [Money](../../../docs/glossary.md#money),
|
||||||
пример: [decimal.ts](../../src/tbank/decimal.ts). UI formatting не меняет
|
пример: [decimal.ts](../../src/common/decimal.ts). UI formatting не меняет
|
||||||
backend ownership расчётов и доменную семантику.
|
backend ownership расчётов и доменную семантику.
|
||||||
|
|
||||||
## Schema и migration
|
## Schema и migration
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BankCard" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"bankName" TEXT,
|
||||||
|
"lastFour" TEXT,
|
||||||
|
"openingBalanceKopecks" BIGINT NOT NULL,
|
||||||
|
"openingDate" TEXT NOT NULL,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"archivedAt" DATETIME,
|
||||||
|
"createFingerprint" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "BankCard_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ExpenseCategory" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"nameKey" TEXT NOT NULL,
|
||||||
|
"seedKey" TEXT,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
CONSTRAINT "ExpenseCategory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BankCardOperation" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"cardId" TEXT NOT NULL,
|
||||||
|
"targetCardId" TEXT,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"originalExpenseId" TEXT,
|
||||||
|
"amountKopecks" BIGINT NOT NULL,
|
||||||
|
"date" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"deletedAt" DATETIME,
|
||||||
|
"createFingerprint" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "BankCardOperation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "BankCardOperation_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "BankCard" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "BankCardOperation_targetCardId_fkey" FOREIGN KEY ("targetCardId") REFERENCES "BankCard" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "BankCardOperation_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ExpenseCategory" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "BankCardOperation_originalExpenseId_fkey" FOREIGN KEY ("originalExpenseId") REFERENCES "BankCardOperation" ("id") ON DELETE RESTRICT ON UPDATE NO ACTION
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BankCard_userId_idx" ON "BankCard"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ExpenseCategory_userId_nameKey_key" ON "ExpenseCategory"("userId", "nameKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ExpenseCategory_userId_seedKey_key" ON "ExpenseCategory"("userId", "seedKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BankCardOperation_userId_cardId_date_idx" ON "BankCardOperation"("userId", "cardId", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BankCardOperation_userId_targetCardId_date_idx" ON "BankCardOperation"("userId", "targetCardId", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BankCardOperation_originalExpenseId_idx" ON "BankCardOperation"("originalExpenseId");
|
||||||
@@ -12,6 +12,9 @@ model User {
|
|||||||
id String @id
|
id String @id
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
|
bankCards BankCard[]
|
||||||
|
expenseCategories ExpenseCategory[]
|
||||||
|
bankCardOperations BankCardOperation[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
tbankConnection TbankConnection?
|
tbankConnection TbankConnection?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -37,3 +40,60 @@ model Session {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BankCard {
|
||||||
|
id String @id
|
||||||
|
userId String
|
||||||
|
name String
|
||||||
|
bankName String?
|
||||||
|
lastFour String?
|
||||||
|
openingBalanceKopecks BigInt
|
||||||
|
openingDate String
|
||||||
|
version Int @default(1)
|
||||||
|
archivedAt DateTime?
|
||||||
|
createFingerprint String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
|
operations BankCardOperation[] @relation("SourceCard")
|
||||||
|
incomingOperations BankCardOperation[] @relation("TargetCard")
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
model ExpenseCategory {
|
||||||
|
id String @id
|
||||||
|
userId String
|
||||||
|
name String
|
||||||
|
nameKey String
|
||||||
|
seedKey String?
|
||||||
|
version Int @default(1)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
|
operations BankCardOperation[]
|
||||||
|
@@unique([userId, nameKey])
|
||||||
|
@@unique([userId, seedKey])
|
||||||
|
}
|
||||||
|
model BankCardOperation {
|
||||||
|
id String @id
|
||||||
|
userId String
|
||||||
|
kind String
|
||||||
|
cardId String
|
||||||
|
targetCardId String?
|
||||||
|
categoryId String?
|
||||||
|
originalExpenseId String?
|
||||||
|
amountKopecks BigInt
|
||||||
|
date String
|
||||||
|
description String?
|
||||||
|
version Int @default(1)
|
||||||
|
deletedAt DateTime?
|
||||||
|
createFingerprint String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
|
card BankCard @relation("SourceCard", fields: [cardId], references: [id], onDelete: Restrict)
|
||||||
|
targetCard BankCard? @relation("TargetCard", fields: [targetCardId], references: [id], onDelete: Restrict)
|
||||||
|
category ExpenseCategory? @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||||
|
originalExpense BankCardOperation? @relation("RefundExpense", fields: [originalExpenseId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||||
|
refunds BankCardOperation[] @relation("RefundExpense")
|
||||||
|
@@index([userId, cardId, date])
|
||||||
|
@@index([userId, targetCardId, date])
|
||||||
|
@@index([originalExpenseId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
import { BankCardsModule } from './bank-cards/bank-cards.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { LoggingModule } from './common/logging/logging.module';
|
import { LoggingModule } from './common/logging/logging.module';
|
||||||
import { Environment, validateEnv } from './config/env.validation';
|
import { Environment, validateEnv } from './config/env.validation';
|
||||||
@@ -49,6 +50,7 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module';
|
|||||||
LoggingModule,
|
LoggingModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
BankCardsModule,
|
||||||
TbankConnectionModule,
|
TbankConnectionModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import {
|
||||||
|
bankBalance,
|
||||||
|
bankSummary,
|
||||||
|
isCalendarDate,
|
||||||
|
moscowToday,
|
||||||
|
} from './bank-card.calculations';
|
||||||
|
|
||||||
|
describe('bank card calculations', () => {
|
||||||
|
it('uses Moscow midnight for today and the current month', () => {
|
||||||
|
expect(moscowToday(new Date('2026-08-31T20:59:59.999Z'))).toBe(
|
||||||
|
'2026-08-31',
|
||||||
|
);
|
||||||
|
expect(moscowToday(new Date('2026-08-31T21:00:00.000Z'))).toBe(
|
||||||
|
'2026-09-01',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'0001-01-01',
|
||||||
|
'9999-12-31',
|
||||||
|
'2024-02-29',
|
||||||
|
'2000-02-29',
|
||||||
|
'2026-09-12',
|
||||||
|
])('accepts calendar date %s', (value) => {
|
||||||
|
expect(isCalendarDate(value)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'0000-01-01',
|
||||||
|
'10000-01-01',
|
||||||
|
'2026-02-29',
|
||||||
|
'1900-02-29',
|
||||||
|
'2026-04-31',
|
||||||
|
'2026-00-01',
|
||||||
|
'2026-13-01',
|
||||||
|
'2026-01-00',
|
||||||
|
'2026-01-32',
|
||||||
|
'2026-1-01',
|
||||||
|
'2026-09-12\n',
|
||||||
|
'2026-09-12T00:00:00Z',
|
||||||
|
'',
|
||||||
|
])('rejects invalid calendar date %s', (value) => {
|
||||||
|
expect(isCalendarDate(value)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds expenses, topups, refunds and both transfer sides exactly', () => {
|
||||||
|
const operations = [
|
||||||
|
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 10n },
|
||||||
|
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 20n },
|
||||||
|
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 15n },
|
||||||
|
{ kind: 'refund', cardId: 'a', targetCardId: null, amountKopecks: 5n },
|
||||||
|
{ kind: 'transfer', cardId: 'a', targetCardId: 'b', amountKopecks: 120n },
|
||||||
|
{ kind: 'expense', cardId: 'c', targetCardId: null, amountKopecks: 300n },
|
||||||
|
];
|
||||||
|
expect(bankBalance('a', 100n, operations.slice(0, 2))).toBe(70n);
|
||||||
|
expect(bankBalance('a', 100n, operations)).toBe(-30n);
|
||||||
|
expect(bankBalance('b', 0n, operations)).toBe(120n);
|
||||||
|
expect(bankBalance('unused', -1n, operations)).toBe(-1n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not bound computed balances by the storage range', () => {
|
||||||
|
expect(
|
||||||
|
bankBalance('a', 2n ** 63n - 1n, [
|
||||||
|
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 1n },
|
||||||
|
]),
|
||||||
|
).toBe(2n ** 63n);
|
||||||
|
expect(
|
||||||
|
bankBalance('a', -(2n ** 63n), [
|
||||||
|
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 1n },
|
||||||
|
]),
|
||||||
|
).toBe(-(2n ** 63n) - 1n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts refunds in their own month and inherited category, excluding transfers and topups', () => {
|
||||||
|
const operations = [
|
||||||
|
{
|
||||||
|
kind: 'expense',
|
||||||
|
date: '2026-08-31',
|
||||||
|
categoryId: 'food',
|
||||||
|
amountKopecks: 1000n,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'refund',
|
||||||
|
date: '2026-09-01',
|
||||||
|
categoryId: 'food',
|
||||||
|
amountKopecks: 500n,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'expense',
|
||||||
|
date: '2026-09-30',
|
||||||
|
categoryId: 'food',
|
||||||
|
amountKopecks: 100n,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'expense',
|
||||||
|
date: '2026-09-03',
|
||||||
|
categoryId: 'travel',
|
||||||
|
amountKopecks: 20n,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'transfer',
|
||||||
|
date: '2026-09-03',
|
||||||
|
categoryId: null,
|
||||||
|
amountKopecks: 2000n,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'topup',
|
||||||
|
date: '2026-09-03',
|
||||||
|
categoryId: null,
|
||||||
|
amountKopecks: 3000n,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'expense',
|
||||||
|
date: '2026-10-01',
|
||||||
|
categoryId: 'travel',
|
||||||
|
amountKopecks: 5000n,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(bankSummary('2026-09', operations)).toEqual({
|
||||||
|
expenses: 120n,
|
||||||
|
refunds: 500n,
|
||||||
|
net: -380n,
|
||||||
|
categories: [
|
||||||
|
{ categoryId: 'food', expenses: 100n, refunds: 500n, net: -400n },
|
||||||
|
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(bankSummary('2026-07', operations)).toEqual({
|
||||||
|
expenses: 0n,
|
||||||
|
refunds: 0n,
|
||||||
|
net: 0n,
|
||||||
|
categories: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps summary sums beyond signed 64-bit exact', () => {
|
||||||
|
const operation = {
|
||||||
|
kind: 'expense',
|
||||||
|
date: '2026-09-01',
|
||||||
|
categoryId: 'food',
|
||||||
|
amountKopecks: 2n ** 63n - 1n,
|
||||||
|
};
|
||||||
|
const total = 2n * (2n ** 63n - 1n);
|
||||||
|
expect(bankSummary('2026-09', [operation, operation])).toEqual({
|
||||||
|
expenses: total,
|
||||||
|
refunds: 0n,
|
||||||
|
net: total,
|
||||||
|
categories: [
|
||||||
|
{ categoryId: 'food', expenses: total, refunds: 0n, net: total },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export function moscowToday(now = new Date()): string {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Europe/Moscow',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCalendarDate(value: string): boolean {
|
||||||
|
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||||
|
if (value.startsWith('0000')) return false;
|
||||||
|
const date = new Date(`${value}T00:00:00.000Z`);
|
||||||
|
return (
|
||||||
|
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bankBalance(
|
||||||
|
cardId: string,
|
||||||
|
opening: bigint,
|
||||||
|
operations: Array<{
|
||||||
|
kind: string;
|
||||||
|
cardId: string;
|
||||||
|
targetCardId: string | null;
|
||||||
|
amountKopecks: bigint;
|
||||||
|
}>,
|
||||||
|
): bigint {
|
||||||
|
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||||
|
let balance = opening;
|
||||||
|
for (const operation of operations) {
|
||||||
|
if (operation.cardId === cardId) {
|
||||||
|
if (operation.kind === 'expense' || operation.kind === 'transfer')
|
||||||
|
balance -= operation.amountKopecks;
|
||||||
|
else if (operation.kind === 'topup' || operation.kind === 'refund')
|
||||||
|
balance += operation.amountKopecks;
|
||||||
|
}
|
||||||
|
if (operation.kind === 'transfer' && operation.targetCardId === cardId)
|
||||||
|
balance += operation.amountKopecks;
|
||||||
|
}
|
||||||
|
return balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bankSummary(
|
||||||
|
month: string,
|
||||||
|
operations: Array<{
|
||||||
|
kind: string;
|
||||||
|
date: string;
|
||||||
|
amountKopecks: bigint;
|
||||||
|
categoryId: string | null;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
let expenses = 0n;
|
||||||
|
let refunds = 0n;
|
||||||
|
const categories = new Map<
|
||||||
|
string,
|
||||||
|
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint }
|
||||||
|
>();
|
||||||
|
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||||
|
for (const operation of operations) {
|
||||||
|
if (operation.date.slice(0, 7) !== month) continue;
|
||||||
|
if (operation.kind !== 'expense' && operation.kind !== 'refund') continue;
|
||||||
|
const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n;
|
||||||
|
const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n;
|
||||||
|
expenses += expense;
|
||||||
|
refunds += refund;
|
||||||
|
if (operation.categoryId !== null) {
|
||||||
|
const category = categories.get(operation.categoryId) ?? {
|
||||||
|
categoryId: operation.categoryId,
|
||||||
|
expenses: 0n,
|
||||||
|
refunds: 0n,
|
||||||
|
net: 0n,
|
||||||
|
};
|
||||||
|
category.expenses += expense;
|
||||||
|
category.refunds += refund;
|
||||||
|
category.net = category.expenses - category.refunds;
|
||||||
|
categories.set(category.categoryId, category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
expenses,
|
||||||
|
refunds,
|
||||||
|
net: expenses - refunds,
|
||||||
|
categories: [...categories.values()],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import {
|
||||||
|
ApiProperty,
|
||||||
|
ApiPropertyOptional,
|
||||||
|
OmitType,
|
||||||
|
PartialType,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDefined,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
|
||||||
|
const trim = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' ? value.trim() : value;
|
||||||
|
export const bankOperationKinds = [
|
||||||
|
'expense',
|
||||||
|
'topup',
|
||||||
|
'transfer',
|
||||||
|
'refund',
|
||||||
|
] as const;
|
||||||
|
export type BankOperationKind = (typeof bankOperationKinds)[number];
|
||||||
|
export class RubMoneyInputDto {
|
||||||
|
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) currency: string;
|
||||||
|
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^-?\d+(\.\d{1,2})?$/)
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
export class CreateBankCardDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string;
|
||||||
|
@ApiProperty({ minLength: 1, maxLength: 100 })
|
||||||
|
@Transform(trim)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(trim)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
bankName?: string | null;
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^[0-9]{4}$/)
|
||||||
|
lastFour?: string | null;
|
||||||
|
@ApiProperty({ type: RubMoneyInputDto })
|
||||||
|
@IsDefined()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => RubMoneyInputDto)
|
||||||
|
openingBalance: MoneyDto;
|
||||||
|
@ApiProperty({ format: 'date' })
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
openingDate: string;
|
||||||
|
}
|
||||||
|
export class UpdateBankCardDto extends PartialType(
|
||||||
|
OmitType(CreateBankCardDto, ['id'] as const),
|
||||||
|
{ skipNullProperties: false },
|
||||||
|
) {
|
||||||
|
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
|
@IsBoolean()
|
||||||
|
archived?: boolean;
|
||||||
|
}
|
||||||
|
export class CategoryNameDto {
|
||||||
|
@ApiProperty({ minLength: 1, maxLength: 80 })
|
||||||
|
@Transform(trim)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
export class UpdateExpenseCategoryDto extends CategoryNameDto {
|
||||||
|
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
|
||||||
|
}
|
||||||
|
export class CreateBankOperationDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string;
|
||||||
|
@ApiProperty({ enum: bankOperationKinds })
|
||||||
|
@IsIn(bankOperationKinds)
|
||||||
|
kind: BankOperationKind;
|
||||||
|
@ApiProperty({ format: 'uuid' }) @IsUUID() cardId: string;
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
|
@IsUUID()
|
||||||
|
targetCardId?: string;
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
|
@IsUUID()
|
||||||
|
originalExpenseId?: string;
|
||||||
|
@ApiProperty({ type: RubMoneyInputDto })
|
||||||
|
@IsDefined()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => RubMoneyInputDto)
|
||||||
|
amount: MoneyDto;
|
||||||
|
@ApiProperty({ format: 'date' })
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
date: string;
|
||||||
|
@ApiPropertyOptional({ maxLength: 500 })
|
||||||
|
@ValidateIf((_object, value: unknown) => value !== undefined)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
export class UpdateBankOperationDto extends PartialType(
|
||||||
|
OmitType(CreateBankOperationDto, ['id'] as const),
|
||||||
|
{ skipNullProperties: false },
|
||||||
|
) {
|
||||||
|
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
|
||||||
|
}
|
||||||
|
export class BankCardsQueryDto {
|
||||||
|
@ApiPropertyOptional({ enum: ['true', 'false'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['true', 'false'])
|
||||||
|
includeArchived?: string;
|
||||||
|
}
|
||||||
|
export class BankPageQueryDto {
|
||||||
|
@ApiPropertyOptional({ minimum: 1, default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
export class BankOperationsQueryDto extends BankPageQueryDto {
|
||||||
|
@ApiProperty({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' })
|
||||||
|
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
export class BankSummaryQueryDto {
|
||||||
|
@ApiProperty({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' })
|
||||||
|
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||||
|
month: string;
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
cardId?: string;
|
||||||
|
}
|
||||||
|
export class BankVersionQueryDto {
|
||||||
|
@ApiProperty({ minimum: 1 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
export class ExpenseCategoryDto {
|
||||||
|
@ApiProperty({ type: String }) id: string;
|
||||||
|
@ApiProperty({ type: String }) name: string;
|
||||||
|
@ApiProperty({ type: Boolean }) isDefault: boolean;
|
||||||
|
@ApiProperty({ type: Number }) version: number;
|
||||||
|
}
|
||||||
|
export class BankCardDto {
|
||||||
|
@ApiProperty({ type: String }) id: string;
|
||||||
|
@ApiProperty({ type: String }) name: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) bankName: string | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) lastFour: string | null;
|
||||||
|
@ApiProperty({ type: MoneyDto }) openingBalance: MoneyDto;
|
||||||
|
@ApiProperty({ type: String }) openingDate: string;
|
||||||
|
@ApiProperty({ type: MoneyDto }) balance: MoneyDto;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) archivedAt: string | null;
|
||||||
|
@ApiProperty({ type: Number }) version: number;
|
||||||
|
}
|
||||||
|
export class BankOperationDto {
|
||||||
|
@ApiProperty({ type: String }) id: string;
|
||||||
|
@ApiProperty({ enum: bankOperationKinds }) kind: BankOperationKind;
|
||||||
|
@ApiProperty({ type: String }) cardId: string;
|
||||||
|
@ApiProperty({ type: String }) cardName: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) targetCardId: string | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) targetCardName: string | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) categoryId: string | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) categoryName: string | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) originalExpenseId:
|
||||||
|
string | null;
|
||||||
|
@ApiProperty({ type: MoneyDto }) amount: MoneyDto;
|
||||||
|
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||||
|
availableRefund: MoneyDto | null;
|
||||||
|
@ApiProperty({ type: String }) date: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) description: string | null;
|
||||||
|
@ApiProperty({ type: Number }) version: number;
|
||||||
|
@ApiProperty({ type: String }) createdAt: string;
|
||||||
|
}
|
||||||
|
export class BankOperationsPageDto {
|
||||||
|
@ApiProperty({ type: BankOperationDto, isArray: true })
|
||||||
|
items: BankOperationDto[];
|
||||||
|
@ApiProperty({ type: Number }) page: number;
|
||||||
|
@ApiProperty({ type: Number }) total: number;
|
||||||
|
@ApiProperty({ type: Boolean }) hasNext: boolean;
|
||||||
|
}
|
||||||
|
export class BankSummaryCategoryDto {
|
||||||
|
@ApiProperty({ type: String }) categoryId: string;
|
||||||
|
@ApiProperty({ type: String }) categoryName: string;
|
||||||
|
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
|
||||||
|
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
|
||||||
|
@ApiProperty({ type: MoneyDto }) net: MoneyDto;
|
||||||
|
}
|
||||||
|
export class BankSummaryDto {
|
||||||
|
@ApiProperty({ type: String }) month: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) cardId: string | null;
|
||||||
|
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
|
||||||
|
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
|
||||||
|
@ApiProperty({ type: MoneyDto }) net: MoneyDto;
|
||||||
|
@ApiProperty({ type: BankSummaryCategoryDto, isArray: true })
|
||||||
|
categories: BankSummaryCategoryDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiTags,
|
||||||
|
ApiResponse,
|
||||||
|
getSchemaPath,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { AccessPayload } from '../auth/auth.types';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import {
|
||||||
|
ApiProblemResponses,
|
||||||
|
ProblemDetailsDto,
|
||||||
|
} from '../common/decorators/api-safe-error-response.decorator';
|
||||||
|
import {
|
||||||
|
BankCardDto,
|
||||||
|
BankCardsQueryDto,
|
||||||
|
BankOperationDto,
|
||||||
|
BankOperationsPageDto,
|
||||||
|
BankOperationsQueryDto,
|
||||||
|
BankPageQueryDto,
|
||||||
|
BankSummaryDto,
|
||||||
|
BankSummaryQueryDto,
|
||||||
|
BankVersionQueryDto,
|
||||||
|
CategoryNameDto,
|
||||||
|
CreateBankCardDto,
|
||||||
|
CreateBankOperationDto,
|
||||||
|
ExpenseCategoryDto,
|
||||||
|
UpdateBankCardDto,
|
||||||
|
UpdateBankOperationDto,
|
||||||
|
UpdateExpenseCategoryDto,
|
||||||
|
} from './bank-card.dto';
|
||||||
|
import { BankCardsService } from './bank-cards.service';
|
||||||
|
type AuthRequest = Request & { user: AccessPayload };
|
||||||
|
@ApiResponse({
|
||||||
|
status: 409,
|
||||||
|
description:
|
||||||
|
'BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT',
|
||||||
|
content: {
|
||||||
|
'application/problem+json': {
|
||||||
|
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 410,
|
||||||
|
description:
|
||||||
|
'BANK_OPERATION_DELETED: creation replay refers to a deleted operation',
|
||||||
|
content: {
|
||||||
|
'application/problem+json': {
|
||||||
|
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiTags('bank-cards')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiProblemResponses()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('bank-cards')
|
||||||
|
export class BankCardsController {
|
||||||
|
constructor(private readonly cards: BankCardsService) {}
|
||||||
|
@Get()
|
||||||
|
@ApiOkResponse({ type: BankCardDto, isArray: true })
|
||||||
|
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
|
||||||
|
return this.cards.list(req.user.sub, query.includeArchived === 'true');
|
||||||
|
}
|
||||||
|
@Post()
|
||||||
|
@ApiCreatedResponse({ type: BankCardDto })
|
||||||
|
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
|
||||||
|
return this.cards.createCard(req.user.sub, body);
|
||||||
|
}
|
||||||
|
@Get('categories')
|
||||||
|
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
|
||||||
|
categories(@Req() req: AuthRequest) {
|
||||||
|
return this.cards.categories(req.user.sub);
|
||||||
|
}
|
||||||
|
@Post('categories')
|
||||||
|
@ApiCreatedResponse({ type: ExpenseCategoryDto })
|
||||||
|
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
|
||||||
|
return this.cards.createCategory(req.user.sub, body.name);
|
||||||
|
}
|
||||||
|
@Patch('categories/:id')
|
||||||
|
@ApiOkResponse({ type: ExpenseCategoryDto })
|
||||||
|
updateCategory(
|
||||||
|
@Req() req: AuthRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: UpdateExpenseCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.cards.updateCategory(req.user.sub, id, body.name, body.version);
|
||||||
|
}
|
||||||
|
@Get('summary')
|
||||||
|
@ApiOkResponse({ type: BankSummaryDto })
|
||||||
|
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
|
||||||
|
return this.cards.summary(req.user.sub, query);
|
||||||
|
}
|
||||||
|
@Post('operations')
|
||||||
|
@ApiCreatedResponse({ type: BankOperationDto })
|
||||||
|
createOperation(
|
||||||
|
@Req() req: AuthRequest,
|
||||||
|
@Body() body: CreateBankOperationDto,
|
||||||
|
) {
|
||||||
|
return this.cards.createOperation(req.user.sub, body);
|
||||||
|
}
|
||||||
|
@Get('operations/:id')
|
||||||
|
@ApiOkResponse({ type: BankOperationDto })
|
||||||
|
operation(@Req() req: AuthRequest, @Param('id') id: string) {
|
||||||
|
return this.cards.getOperation(req.user.sub, id);
|
||||||
|
}
|
||||||
|
@Patch('operations/:id')
|
||||||
|
@ApiOkResponse({ type: BankOperationDto })
|
||||||
|
updateOperation(
|
||||||
|
@Req() req: AuthRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: UpdateBankOperationDto,
|
||||||
|
) {
|
||||||
|
return this.cards.updateOperation(req.user.sub, id, body);
|
||||||
|
}
|
||||||
|
@Delete('operations/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
deleteOperation(
|
||||||
|
@Req() req: AuthRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query() query: BankVersionQueryDto,
|
||||||
|
) {
|
||||||
|
return this.cards.deleteOperation(req.user.sub, id, query.version);
|
||||||
|
}
|
||||||
|
@Get(':id/operations')
|
||||||
|
@ApiOkResponse({ type: BankOperationsPageDto })
|
||||||
|
history(
|
||||||
|
@Req() req: AuthRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query() query: BankOperationsQueryDto,
|
||||||
|
) {
|
||||||
|
return this.cards.history(req.user.sub, id, query);
|
||||||
|
}
|
||||||
|
@Get(':id/expenses')
|
||||||
|
@ApiOkResponse({ type: BankOperationsPageDto })
|
||||||
|
expenses(
|
||||||
|
@Req() req: AuthRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query() query: BankPageQueryDto,
|
||||||
|
) {
|
||||||
|
return this.cards.history(req.user.sub, id, query, true);
|
||||||
|
}
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOkResponse({ type: BankCardDto })
|
||||||
|
get(@Req() req: AuthRequest, @Param('id') id: string) {
|
||||||
|
return this.cards.get(req.user.sub, id);
|
||||||
|
}
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOkResponse({ type: BankCardDto })
|
||||||
|
update(
|
||||||
|
@Req() req: AuthRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: UpdateBankCardDto,
|
||||||
|
) {
|
||||||
|
return this.cards.updateCard(req.user.sub, id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BankCardsController } from './bank-cards.controller';
|
||||||
|
import { BankCardsService } from './bank-cards.service';
|
||||||
|
@Module({ controllers: [BankCardsController], providers: [BankCardsService] })
|
||||||
|
export class BankCardsModule {}
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
GoneException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
Prisma,
|
||||||
|
BankCard,
|
||||||
|
BankCardOperation,
|
||||||
|
} from '../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { parseRubMoney, rubMoney } from '../common/rub-money';
|
||||||
|
import {
|
||||||
|
bankBalance,
|
||||||
|
bankSummary,
|
||||||
|
isCalendarDate,
|
||||||
|
moscowToday,
|
||||||
|
} from './bank-card.calculations';
|
||||||
|
import {
|
||||||
|
BankCardDto,
|
||||||
|
BankOperationDto,
|
||||||
|
BankOperationsQueryDto,
|
||||||
|
BankPageQueryDto,
|
||||||
|
BankSummaryQueryDto,
|
||||||
|
CreateBankCardDto,
|
||||||
|
CreateBankOperationDto,
|
||||||
|
UpdateBankCardDto,
|
||||||
|
UpdateBankOperationDto,
|
||||||
|
} from './bank-card.dto';
|
||||||
|
|
||||||
|
type Tx = Prisma.TransactionClient;
|
||||||
|
const seeds = [
|
||||||
|
'Продукты',
|
||||||
|
'Кафе',
|
||||||
|
'Такси',
|
||||||
|
'Транспорт',
|
||||||
|
'Жильё',
|
||||||
|
'Здоровье',
|
||||||
|
'Покупки',
|
||||||
|
'Развлечения',
|
||||||
|
'Другое',
|
||||||
|
];
|
||||||
|
const fail = (code = 'BANK_INVALID_INPUT'): never => {
|
||||||
|
throw new BadRequestException({ code });
|
||||||
|
};
|
||||||
|
const conflict = (code = 'BANK_VERSION_CONFLICT'): never => {
|
||||||
|
throw new ConflictException({ code });
|
||||||
|
};
|
||||||
|
const missing = (): never => {
|
||||||
|
throw new NotFoundException({ code: 'BANK_NOT_FOUND' });
|
||||||
|
};
|
||||||
|
const fingerprint = (value: unknown) =>
|
||||||
|
createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify(value, (_key, item: unknown) =>
|
||||||
|
typeof item === 'bigint' ? item.toString() : item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
function amount(value: Parameters<typeof parseRubMoney>[0], positive = false) {
|
||||||
|
try {
|
||||||
|
return parseRubMoney(value, positive);
|
||||||
|
} catch {
|
||||||
|
return fail('BANK_INVALID_MONEY');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function calendar(value: string) {
|
||||||
|
if (!isCalendarDate(value)) fail('BANK_INVALID_DATE');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function currentDate(value: string) {
|
||||||
|
calendar(value);
|
||||||
|
if (value > moscowToday()) fail('BANK_INVALID_DATE');
|
||||||
|
}
|
||||||
|
function name(value: string, max: number) {
|
||||||
|
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
|
||||||
|
fail();
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
function cardInput(dto: CreateBankCardDto) {
|
||||||
|
const bankName = dto.bankName?.trim() || null;
|
||||||
|
if (bankName && bankName.length > 100) fail();
|
||||||
|
if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail();
|
||||||
|
return {
|
||||||
|
name: name(dto.name, 100),
|
||||||
|
bankName,
|
||||||
|
lastFour: dto.lastFour || null,
|
||||||
|
openingBalanceKopecks: amount(dto.openingBalance),
|
||||||
|
openingDate: calendar(dto.openingDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function operationInput(dto: CreateBankOperationDto) {
|
||||||
|
if (
|
||||||
|
dto.description != null &&
|
||||||
|
(typeof dto.description !== 'string' || dto.description.length > 500)
|
||||||
|
)
|
||||||
|
fail();
|
||||||
|
return {
|
||||||
|
kind: dto.kind,
|
||||||
|
cardId: dto.cardId,
|
||||||
|
targetCardId: dto.targetCardId ?? null,
|
||||||
|
categoryId: dto.categoryId ?? null,
|
||||||
|
originalExpenseId: dto.originalExpenseId ?? null,
|
||||||
|
amountKopecks: amount(dto.amount, true),
|
||||||
|
date: calendar(dto.date),
|
||||||
|
description: dto.description || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BankCardsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
private async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(run, {
|
||||||
|
maxWait: 10000,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError
|
||||||
|
? error.code
|
||||||
|
: '';
|
||||||
|
const busy =
|
||||||
|
code === 'P2034' ||
|
||||||
|
code === 'P2028' ||
|
||||||
|
(error instanceof Error &&
|
||||||
|
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||||
|
if ((busy || code === 'P2002') && attempt < 2) continue;
|
||||||
|
if (busy) conflict('BANK_WRITE_CONFLICT');
|
||||||
|
if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async card(tx: Tx, userId: string, id: string, active = false) {
|
||||||
|
const card = await tx.bankCard.findFirst({ where: { id, userId } });
|
||||||
|
if (!card) return missing();
|
||||||
|
if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED');
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
private async operation(tx: Tx, userId: string, id: string) {
|
||||||
|
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
|
||||||
|
if (!row) return missing();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
private async seed(tx: Tx, userId: string) {
|
||||||
|
for (const [index, label] of seeds.entries()) {
|
||||||
|
const seedKey = index === 8 ? 'other' : String(index);
|
||||||
|
if (
|
||||||
|
!(await tx.expenseCategory.findUnique({
|
||||||
|
where: { userId_seedKey: { userId, seedKey } },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
await tx.expenseCategory.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
seedKey,
|
||||||
|
name: label,
|
||||||
|
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
||||||
|
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,
|
||||||
|
openingDate: card.openingDate,
|
||||||
|
openingBalance: rubMoney(card.openingBalanceKopecks),
|
||||||
|
balance: rubMoney(bankBalance(card.id, card.openingBalanceKopecks, rows)),
|
||||||
|
version: card.version,
|
||||||
|
archivedAt: card.archivedAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private async operationView(
|
||||||
|
tx: Tx,
|
||||||
|
row: BankCardOperation,
|
||||||
|
): Promise<BankOperationDto> {
|
||||||
|
const card = await this.card(tx, row.userId, row.cardId);
|
||||||
|
const target = row.targetCardId
|
||||||
|
? await this.card(tx, row.userId, row.targetCardId)
|
||||||
|
: null;
|
||||||
|
const source = row.originalExpenseId
|
||||||
|
? await this.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 === '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 === 'expense' || source
|
||||||
|
? rubMoney(
|
||||||
|
(source?.amountKopecks ?? row.amountKopecks) -
|
||||||
|
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
date: row.date,
|
||||||
|
description: row.description,
|
||||||
|
version: row.version,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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.card(tx, userId, id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
createCard(userId: string, dto: CreateBankCardDto) {
|
||||||
|
const input = cardInput(dto),
|
||||||
|
hash = fingerprint(input);
|
||||||
|
return this.write(async (tx) => {
|
||||||
|
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
||||||
|
if (existing) {
|
||||||
|
if (existing.userId !== userId) missing();
|
||||||
|
if (existing.createFingerprint !== hash)
|
||||||
|
conflict('BANK_INTENT_CONFLICT');
|
||||||
|
return this.cardView(tx, existing);
|
||||||
|
}
|
||||||
|
currentDate(input.openingDate);
|
||||||
|
await this.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.write(async (tx) => {
|
||||||
|
const previous = await this.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 ?? undefined)
|
||||||
|
: (dto.bankName ?? undefined),
|
||||||
|
lastFour:
|
||||||
|
dto.lastFour === undefined
|
||||||
|
? (previous.lastFour ?? undefined)
|
||||||
|
: (dto.lastFour ?? undefined),
|
||||||
|
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('BANK_CARD_ARCHIVED');
|
||||||
|
currentDate(input.openingDate);
|
||||||
|
if (
|
||||||
|
await tx.bankCardOperation.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
date: { lt: input.openingDate },
|
||||||
|
OR: [{ cardId: id }, { targetCardId: id }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
fail('BANK_OPENING_DATE_CONFLICT');
|
||||||
|
const changed = await tx.bankCard.updateMany({
|
||||||
|
where: { id, userId, version: dto.version },
|
||||||
|
data: {
|
||||||
|
...input,
|
||||||
|
version: { increment: 1 },
|
||||||
|
...(dto.archived === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
archivedAt: dto.archived
|
||||||
|
? (previous.archivedAt ?? new Date())
|
||||||
|
: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (changed.count !== 1) conflict();
|
||||||
|
return this.cardView(tx, await this.card(tx, userId, id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async categories(userId: string) {
|
||||||
|
return (
|
||||||
|
await this.prisma.expenseCategory.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||||
|
})
|
||||||
|
).map(({ id, name, seedKey, version }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
isDefault: seedKey === 'other',
|
||||||
|
version,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
createCategory(userId: string, value: string) {
|
||||||
|
const label = name(value, 80);
|
||||||
|
return this.write(async (tx) => {
|
||||||
|
await this.seed(tx, userId);
|
||||||
|
const row = await tx.expenseCategory.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
name: label,
|
||||||
|
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
isDefault: false,
|
||||||
|
version: row.version,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateCategory(userId: string, id: string, value: string, version: number) {
|
||||||
|
const label = name(value, 80);
|
||||||
|
return this.write(async (tx) => {
|
||||||
|
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
|
||||||
|
if (!row) missing();
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
await tx.expenseCategory.updateMany({
|
||||||
|
where: { id, userId, version },
|
||||||
|
data: {
|
||||||
|
name: label,
|
||||||
|
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).count !== 1
|
||||||
|
)
|
||||||
|
conflict();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: label,
|
||||||
|
isDefault: row!.seedKey === 'other',
|
||||||
|
version: version + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private async validateOperation(
|
||||||
|
tx: Tx,
|
||||||
|
userId: string,
|
||||||
|
input: ReturnType<typeof operationInput>,
|
||||||
|
old?: BankCardOperation,
|
||||||
|
) {
|
||||||
|
const cards = new Set(
|
||||||
|
[input.cardId, input.targetCardId, old?.cardId, old?.targetCardId].filter(
|
||||||
|
(id): id is string => Boolean(id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
currentDate(input.date);
|
||||||
|
for (const id of cards) {
|
||||||
|
const card = await this.card(tx, userId, id, true);
|
||||||
|
if (
|
||||||
|
(id === input.cardId || id === input.targetCardId) &&
|
||||||
|
input.date < card.openingDate
|
||||||
|
)
|
||||||
|
fail('BANK_INVALID_DATE');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
old &&
|
||||||
|
(old.kind !== input.kind ||
|
||||||
|
old.originalExpenseId !== input.originalExpenseId)
|
||||||
|
)
|
||||||
|
fail('BANK_IMMUTABLE_OPERATION');
|
||||||
|
if (input.kind === 'transfer') {
|
||||||
|
if (
|
||||||
|
!input.targetCardId ||
|
||||||
|
input.targetCardId === input.cardId ||
|
||||||
|
input.categoryId ||
|
||||||
|
input.originalExpenseId
|
||||||
|
)
|
||||||
|
fail();
|
||||||
|
} else if (input.targetCardId) fail();
|
||||||
|
if (input.kind === 'expense') {
|
||||||
|
if (input.originalExpenseId) fail();
|
||||||
|
if (!input.categoryId) {
|
||||||
|
const category = await tx.expenseCategory.findUnique({
|
||||||
|
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
||||||
|
});
|
||||||
|
if (!category) missing();
|
||||||
|
input.categoryId = category!.id;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await tx.expenseCategory.findFirst({
|
||||||
|
where: { userId, id: input.categoryId },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
missing();
|
||||||
|
} else if (input.categoryId) fail();
|
||||||
|
if (input.kind !== 'refund' && input.originalExpenseId) fail();
|
||||||
|
if (input.kind === 'refund') {
|
||||||
|
if (!input.originalExpenseId) fail();
|
||||||
|
const source = await this.operation(tx, userId, input.originalExpenseId!);
|
||||||
|
if (
|
||||||
|
source.deletedAt ||
|
||||||
|
source.kind !== 'expense' ||
|
||||||
|
source.cardId !== input.cardId
|
||||||
|
)
|
||||||
|
fail('BANK_INVALID_REFUND');
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
await tx.bankCardOperation.updateMany({
|
||||||
|
where: {
|
||||||
|
id: source.id,
|
||||||
|
userId,
|
||||||
|
version: source.version,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
data: { version: { increment: 1 } },
|
||||||
|
})
|
||||||
|
).count !== 1
|
||||||
|
)
|
||||||
|
conflict();
|
||||||
|
const refunds = await tx.bankCardOperation.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
originalExpenseId: source.id,
|
||||||
|
deletedAt: null,
|
||||||
|
...(old ? { id: { not: old.id } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (input.date < source.date) fail('BANK_INVALID_DATE');
|
||||||
|
if (
|
||||||
|
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
|
||||||
|
input.amountKopecks >
|
||||||
|
source.amountKopecks
|
||||||
|
)
|
||||||
|
conflict('BANK_REFUND_LIMIT');
|
||||||
|
}
|
||||||
|
if (old?.kind === 'expense') {
|
||||||
|
const refunds = await tx.bankCardOperation.findMany({
|
||||||
|
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
refunds.length &&
|
||||||
|
(input.cardId !== old.cardId ||
|
||||||
|
refunds.some((row) => row.date < input.date) ||
|
||||||
|
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
|
||||||
|
input.amountKopecks)
|
||||||
|
)
|
||||||
|
conflict('BANK_DEPENDENT_REFUNDS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createOperation(userId: string, dto: CreateBankOperationDto) {
|
||||||
|
const normalized = operationInput(dto),
|
||||||
|
hash = fingerprint(normalized);
|
||||||
|
return this.write(async (tx) => {
|
||||||
|
const existing = await tx.bankCardOperation.findUnique({
|
||||||
|
where: { id: dto.id },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
if (existing.userId !== userId) missing();
|
||||||
|
if (existing.createFingerprint !== hash)
|
||||||
|
conflict('BANK_INTENT_CONFLICT');
|
||||||
|
if (existing.deletedAt)
|
||||||
|
throw new GoneException({ code: 'BANK_OPERATION_DELETED' });
|
||||||
|
return this.operationView(tx, existing);
|
||||||
|
}
|
||||||
|
const input = { ...normalized };
|
||||||
|
await this.validateOperation(tx, userId, input);
|
||||||
|
return this.operationView(
|
||||||
|
tx,
|
||||||
|
await tx.bankCardOperation.create({
|
||||||
|
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getOperation(userId: string, id: string) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const row = await this.operation(tx, userId, id);
|
||||||
|
if (row.deletedAt) missing();
|
||||||
|
return this.operationView(tx, row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
|
||||||
|
return this.write(async (tx) => {
|
||||||
|
const old = await this.operation(tx, userId, id);
|
||||||
|
if (old.deletedAt) missing();
|
||||||
|
if (old.version !== dto.version) conflict();
|
||||||
|
const input = operationInput({
|
||||||
|
id,
|
||||||
|
kind: dto.kind ?? (old.kind as CreateBankOperationDto['kind']),
|
||||||
|
cardId: dto.cardId ?? old.cardId,
|
||||||
|
targetCardId: dto.targetCardId ?? old.targetCardId ?? undefined,
|
||||||
|
categoryId: dto.categoryId ?? old.categoryId ?? undefined,
|
||||||
|
originalExpenseId:
|
||||||
|
dto.originalExpenseId ?? old.originalExpenseId ?? undefined,
|
||||||
|
amount: dto.amount ?? rubMoney(old.amountKopecks),
|
||||||
|
date: dto.date ?? old.date,
|
||||||
|
description: dto.description ?? old.description ?? undefined,
|
||||||
|
});
|
||||||
|
await this.validateOperation(tx, userId, input, old);
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
await tx.bankCardOperation.updateMany({
|
||||||
|
where: { id, userId, version: dto.version, deletedAt: null },
|
||||||
|
data: { ...input, version: { increment: 1 } },
|
||||||
|
})
|
||||||
|
).count !== 1
|
||||||
|
)
|
||||||
|
conflict();
|
||||||
|
return this.operationView(tx, await this.operation(tx, userId, id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
deleteOperation(userId: string, id: string, version: number) {
|
||||||
|
return this.write(async (tx) => {
|
||||||
|
const row = await this.operation(tx, userId, id);
|
||||||
|
if (row.deletedAt) return;
|
||||||
|
if (row.version !== version) conflict();
|
||||||
|
await this.card(tx, userId, row.cardId, true);
|
||||||
|
if (row.targetCardId) await this.card(tx, userId, row.targetCardId, true);
|
||||||
|
if (
|
||||||
|
row.kind === 'expense' &&
|
||||||
|
(await tx.bankCardOperation.findFirst({
|
||||||
|
where: { userId, originalExpenseId: id, deletedAt: null },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
conflict('BANK_DEPENDENT_REFUNDS');
|
||||||
|
if (row.originalExpenseId) {
|
||||||
|
const source = await this.operation(tx, userId, row.originalExpenseId);
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
await tx.bankCardOperation.updateMany({
|
||||||
|
where: { id: source.id, userId, version: source.version },
|
||||||
|
data: { version: { increment: 1 } },
|
||||||
|
})
|
||||||
|
).count !== 1
|
||||||
|
)
|
||||||
|
conflict();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
await tx.bankCardOperation.updateMany({
|
||||||
|
where: { id, userId, version, deletedAt: null },
|
||||||
|
data: { deletedAt: new Date(), version: { increment: 1 } },
|
||||||
|
})
|
||||||
|
).count !== 1
|
||||||
|
)
|
||||||
|
conflict();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
history(
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
query: BankOperationsQueryDto | BankPageQueryDto,
|
||||||
|
expenses = false,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await this.card(tx, userId, id);
|
||||||
|
const page = query.page ?? 1,
|
||||||
|
limit = query.limit ?? 50;
|
||||||
|
const where: Prisma.BankCardOperationWhereInput = {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [{ cardId: id }, { targetCardId: id }],
|
||||||
|
...(expenses
|
||||||
|
? { kind: 'expense' }
|
||||||
|
: { date: { startsWith: (query as BankOperationsQueryDto).month } }),
|
||||||
|
};
|
||||||
|
const total = await tx.bankCardOperation.count({ where });
|
||||||
|
const rows = await tx.bankCardOperation.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: await Promise.all(
|
||||||
|
rows.map((row) => this.operationView(tx, row)),
|
||||||
|
),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
hasNext: page * limit < total,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
summary(userId: string, query: BankSummaryQueryDto) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
if (query.cardId) await this.card(tx, userId, query.cardId);
|
||||||
|
const rows = await tx.bankCardOperation.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
date: { startsWith: query.month },
|
||||||
|
kind: { in: ['expense', 'refund'] },
|
||||||
|
...(query.cardId ? { cardId: query.cardId } : {}),
|
||||||
|
},
|
||||||
|
include: { originalExpense: { select: { categoryId: true } } },
|
||||||
|
});
|
||||||
|
const totals = bankSummary(
|
||||||
|
query.month,
|
||||||
|
rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
categoryId: row.originalExpense?.categoryId ?? row.categoryId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const categories = await tx.expenseCategory.findMany({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
month: query.month,
|
||||||
|
cardId: query.cardId ?? null,
|
||||||
|
expenses: rubMoney(totals.expenses),
|
||||||
|
refunds: rubMoney(totals.refunds),
|
||||||
|
net: rubMoney(totals.net),
|
||||||
|
categories: totals.categories.map((row) => ({
|
||||||
|
categoryId: row.categoryId,
|
||||||
|
categoryName: categories.find((c) => c.id === row.categoryId)!.name,
|
||||||
|
expenses: rubMoney(row.expenses),
|
||||||
|
refunds: rubMoney(row.refunds),
|
||||||
|
net: rubMoney(row.net),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type Decimal = { value: bigint; scale: number };
|
||||||
|
|
||||||
|
export function decimalFromString(value: string): Decimal {
|
||||||
|
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
|
||||||
|
throw new Error('Invalid decimal string');
|
||||||
|
const negative = value.startsWith('-');
|
||||||
|
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
||||||
|
return {
|
||||||
|
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
||||||
|
scale: fraction.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function add(left: Decimal, right: Decimal): Decimal {
|
||||||
|
const scale = Math.max(left.scale, right.scale);
|
||||||
|
return {
|
||||||
|
value:
|
||||||
|
left.value * 10n ** BigInt(scale - left.scale) +
|
||||||
|
right.value * 10n ** BigInt(scale - right.scale),
|
||||||
|
scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subtract(left: Decimal, right: Decimal): Decimal {
|
||||||
|
return add(left, { value: -right.value, scale: right.scale });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function multiply(left: Decimal, right: Decimal): Decimal {
|
||||||
|
return { value: left.value * right.value, scale: left.scale + right.scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isZero(value: Decimal): boolean {
|
||||||
|
return value.value === 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compare(left: Decimal, right: Decimal): number {
|
||||||
|
const difference = subtract(left, right).value;
|
||||||
|
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDecimal({ value, scale }: Decimal): string {
|
||||||
|
if (value === 0n) return '0';
|
||||||
|
const sign = value < 0n ? '-' : '';
|
||||||
|
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
|
||||||
|
const digits = (value < 0n ? -value : value)
|
||||||
|
.toString()
|
||||||
|
.padStart(scale + 1, '0');
|
||||||
|
const whole = digits.slice(0, -scale) || '0';
|
||||||
|
const fraction = digits.slice(-scale).replace(/0+$/, '');
|
||||||
|
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round half away from zero; callers choose presentation precision explicitly.
|
||||||
|
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
|
||||||
|
if (right.value === 0n) throw new Error('Division by zero');
|
||||||
|
const numerator = left.value * 10n ** BigInt(right.scale + scale);
|
||||||
|
const denominator = right.value * 10n ** BigInt(left.scale);
|
||||||
|
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
|
||||||
|
const n = numerator < 0n ? -numerator : numerator;
|
||||||
|
const d = denominator < 0n ? -denominator : denominator;
|
||||||
|
return { value: sign * ((n + d / 2n) / d), scale };
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class MoneyDto {
|
||||||
|
@ApiProperty()
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { parseRubMoney, rubMoney } from './rub-money';
|
||||||
|
|
||||||
|
describe('RUB money', () => {
|
||||||
|
it.each([
|
||||||
|
['0', 0n],
|
||||||
|
['-100', -10000n],
|
||||||
|
['0.01', 1n],
|
||||||
|
['123.4', 12340n],
|
||||||
|
['90071992547409.93', 9007199254740993n],
|
||||||
|
['92233720368547758.07', 2n ** 63n - 1n],
|
||||||
|
['-92233720368547758.08', -(2n ** 63n)],
|
||||||
|
])('roundtrips %s exactly', (value, kopecks) => {
|
||||||
|
expect(parseRubMoney({ currency: 'RUB', value })).toBe(kopecks);
|
||||||
|
expect(rubMoney(kopecks)).toEqual({ currency: 'RUB', value });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'92233720368547758.08',
|
||||||
|
'-92233720368547758.09',
|
||||||
|
'1.001',
|
||||||
|
'1.000',
|
||||||
|
'NaN',
|
||||||
|
'Infinity',
|
||||||
|
'1e2',
|
||||||
|
'1,23',
|
||||||
|
'',
|
||||||
|
' 1',
|
||||||
|
'1\n',
|
||||||
|
'01',
|
||||||
|
])('rejects invalid input %s without rounding', (value) => {
|
||||||
|
expect(() => parseRubMoney({ currency: 'RUB', value })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects other currencies and non-string values', () => {
|
||||||
|
expect(() => parseRubMoney({ currency: 'USD', value: '1' })).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
parseRubMoney({ currency: 'RUB', value: 1 as unknown as string }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires positive operation amounts, including at the maximum', () => {
|
||||||
|
for (const value of ['0', '-0.00', '-0.01'])
|
||||||
|
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
|
||||||
|
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
|
||||||
|
expect(
|
||||||
|
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
|
||||||
|
).toBe(2n ** 63n - 1n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats computed sums beyond the storage range without losing precision', () => {
|
||||||
|
expect(rubMoney(2n * (2n ** 63n - 1n))).toEqual({
|
||||||
|
currency: 'RUB',
|
||||||
|
value: '184467440737095516.14',
|
||||||
|
});
|
||||||
|
expect(rubMoney(-1n)).toEqual({ currency: 'RUB', value: '-0.01' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { decimalFromString, formatDecimal } from './decimal';
|
||||||
|
import { MoneyDto } from './money.dto';
|
||||||
|
|
||||||
|
export function parseRubMoney(money: MoneyDto, positive = false): bigint {
|
||||||
|
if (
|
||||||
|
money?.currency !== 'RUB' ||
|
||||||
|
typeof money.value !== 'string' ||
|
||||||
|
money.value !== money.value.trim()
|
||||||
|
)
|
||||||
|
throw new TypeError('Expected RUB money with a decimal string');
|
||||||
|
const amount = decimalFromString(money.value);
|
||||||
|
if (amount.scale > 2)
|
||||||
|
throw new RangeError('RUB money supports at most two decimal places');
|
||||||
|
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
|
||||||
|
if (kopecks < -(2n ** 63n) || kopecks > 2n ** 63n - 1n)
|
||||||
|
throw new RangeError('RUB money is outside the signed 64-bit range');
|
||||||
|
if (positive && kopecks <= 0n)
|
||||||
|
throw new RangeError('Operation amount must be positive');
|
||||||
|
return kopecks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rubMoney(kopecks: bigint): MoneyDto {
|
||||||
|
return {
|
||||||
|
currency: 'RUB',
|
||||||
|
value: formatDecimal({ value: kopecks, scale: 2 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { MoneyDto } from './portfolio.dto';
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
StockSearchQueryDto,
|
StockSearchQueryDto,
|
||||||
StockTickerParamsDto,
|
StockTickerParamsDto,
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import {
|
import {
|
||||||
decimal,
|
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
add,
|
add,
|
||||||
} from './decimal';
|
} from '../common/decimal';
|
||||||
|
import { decimal } from './decimal';
|
||||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||||
import { MoexClient } from './moex.client';
|
import { MoexClient } from './moex.client';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { MoneyDto } from './portfolio.dto';
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
|
||||||
export class PortfolioSummaryDto {
|
export class PortfolioSummaryDto {
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||||
import { decimal, isZero } from './decimal';
|
import { isZero } from '../common/decimal';
|
||||||
|
import { decimal } from './decimal';
|
||||||
import { totalsByCurrency } from './portfolio-totals';
|
import { totalsByCurrency } from './portfolio-totals';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { MoneyDto } from './portfolio.dto';
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
|
||||||
import { AccountReferenceDto } from './account-reference.dto';
|
import { AccountReferenceDto } from './account-reference.dto';
|
||||||
|
|
||||||
export const operationCategories = [
|
export const operationCategories = [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { decimal, formatDecimal } from './decimal';
|
import { formatDecimal } from '../common/decimal';
|
||||||
|
import { decimal } from './decimal';
|
||||||
import {
|
import {
|
||||||
BrokerageOperationDto,
|
BrokerageOperationDto,
|
||||||
OperationCategory,
|
OperationCategory,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { add, decimal, formatDecimal, multiply, subtract } from './decimal';
|
import { add, formatDecimal, multiply, subtract } from '../common/decimal';
|
||||||
|
import { decimal } from './decimal';
|
||||||
|
|
||||||
describe('decimal', () => {
|
describe('decimal', () => {
|
||||||
it('keeps signed nanos exact without floats', () => {
|
it('keeps signed nanos exact without floats', () => {
|
||||||
|
|||||||
+2
-62
@@ -1,18 +1,8 @@
|
|||||||
|
import { Decimal } from '../common/decimal';
|
||||||
|
|
||||||
const NANO = 1_000_000_000n;
|
const NANO = 1_000_000_000n;
|
||||||
|
|
||||||
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
||||||
export type Decimal = { value: bigint; scale: number };
|
|
||||||
|
|
||||||
export function decimalFromString(value: string): Decimal {
|
|
||||||
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
|
|
||||||
throw new Error('Invalid decimal string');
|
|
||||||
const negative = value.startsWith('-');
|
|
||||||
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
|
||||||
return {
|
|
||||||
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
|
||||||
scale: fraction.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decimal(value: ProtoDecimal): Decimal {
|
export function decimal(value: ProtoDecimal): Decimal {
|
||||||
return {
|
return {
|
||||||
@@ -20,53 +10,3 @@ export function decimal(value: ProtoDecimal): Decimal {
|
|||||||
scale: 9,
|
scale: 9,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function add(left: Decimal, right: Decimal): Decimal {
|
|
||||||
const scale = Math.max(left.scale, right.scale);
|
|
||||||
return {
|
|
||||||
value:
|
|
||||||
left.value * 10n ** BigInt(scale - left.scale) +
|
|
||||||
right.value * 10n ** BigInt(scale - right.scale),
|
|
||||||
scale,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subtract(left: Decimal, right: Decimal): Decimal {
|
|
||||||
return add(left, { value: -right.value, scale: right.scale });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function multiply(left: Decimal, right: Decimal): Decimal {
|
|
||||||
return { value: left.value * right.value, scale: left.scale + right.scale };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isZero(value: Decimal): boolean {
|
|
||||||
return value.value === 0n;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compare(left: Decimal, right: Decimal): number {
|
|
||||||
const difference = subtract(left, right).value;
|
|
||||||
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDecimal({ value, scale }: Decimal): string {
|
|
||||||
if (value === 0n) return '0';
|
|
||||||
const sign = value < 0n ? '-' : '';
|
|
||||||
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
|
|
||||||
const digits = (value < 0n ? -value : value)
|
|
||||||
.toString()
|
|
||||||
.padStart(scale + 1, '0');
|
|
||||||
const whole = digits.slice(0, -scale) || '0';
|
|
||||||
const fraction = digits.slice(-scale).replace(/0+$/, '');
|
|
||||||
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round half away from zero; callers choose presentation precision explicitly.
|
|
||||||
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
|
|
||||||
if (right.value === 0n) throw new Error('Division by zero');
|
|
||||||
const numerator = left.value * 10n ** BigInt(right.scale + scale);
|
|
||||||
const denominator = right.value * 10n ** BigInt(left.scale);
|
|
||||||
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
|
|
||||||
const n = numerator < 0n ? -numerator : numerator;
|
|
||||||
const d = denominator < 0n ? -denominator : denominator;
|
|
||||||
return { value: sign * ((n + d / 2n) / d), scale };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsOptional, Matches } from 'class-validator';
|
import { IsOptional, Matches } from 'class-validator';
|
||||||
import { BrokerageOperationDto } from './brokerage-operation.dto';
|
import { BrokerageOperationDto } from './brokerage-operation.dto';
|
||||||
import { MoneyDto } from './portfolio.dto';
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
|
||||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
decimalFromString,
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
subtract,
|
subtract,
|
||||||
} from './decimal';
|
} from '../common/decimal';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DateOnlyRange,
|
DateOnlyRange,
|
||||||
IncomeBucketGranularity,
|
IncomeBucketGranularity,
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
} from './decimal';
|
} from '../common/decimal';
|
||||||
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
|
||||||
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||||
import { StockPositionDto } from './stock.dto';
|
import { StockPositionDto } from './stock.dto';
|
||||||
|
|
||||||
export function instrumentPosition(
|
export function instrumentPosition(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { add, decimalFromString, formatDecimal } from './decimal';
|
import { add, decimalFromString, formatDecimal } from '../common/decimal';
|
||||||
|
|
||||||
|
import { MoneyDto } from '../common/money.dto';
|
||||||
import {
|
import {
|
||||||
MoneyDto,
|
|
||||||
PortfolioPositionDto,
|
PortfolioPositionDto,
|
||||||
PortfolioStructureDto,
|
PortfolioStructureDto,
|
||||||
PortfolioStructureCategoryDto,
|
PortfolioStructureCategoryDto,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
decimal,
|
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
isZero,
|
isZero,
|
||||||
multiply,
|
multiply,
|
||||||
} from './decimal';
|
} from '../common/decimal';
|
||||||
import { MoneyDto } from './portfolio.dto';
|
import { decimal } from './decimal';
|
||||||
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
|
||||||
import { TbankPortfolio, TbankPositions } from './tbank.client';
|
import { TbankPortfolio, TbankPositions } from './tbank.client';
|
||||||
|
|
||||||
function totalsByCurrency(
|
function totalsByCurrency(
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
|
import { MoneyDto } from '../common/money.dto';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { AccountReferenceDto } from './account-reference.dto';
|
import { AccountReferenceDto } from './account-reference.dto';
|
||||||
|
|
||||||
export class MoneyDto {
|
|
||||||
@ApiProperty()
|
|
||||||
currency: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PortfolioPositionDto {
|
export class PortfolioPositionDto {
|
||||||
@ApiPropertyOptional({ type: String, nullable: true })
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
instrumentUid?: string | null;
|
instrumentUid?: string | null;
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
|||||||
import { RequestContextService } from '../common/logging/request-context.service';
|
import { RequestContextService } from '../common/logging/request-context.service';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
decimal,
|
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
isZero,
|
isZero,
|
||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from './decimal';
|
} from '../common/decimal';
|
||||||
|
import { decimal } from './decimal';
|
||||||
import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals';
|
import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals';
|
||||||
import { portfolioStructure } from './portfolio-structure';
|
import { portfolioStructure } from './portfolio-structure';
|
||||||
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankConnectionContext,
|
TbankConnectionContext,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
Matches,
|
Matches,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { MoneyDto, PortfolioPositionDto } from './portfolio.dto';
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
import { PortfolioPositionDto } from './portfolio.dto';
|
||||||
|
|
||||||
export class StockSearchQueryDto {
|
export class StockSearchQueryDto {
|
||||||
@ApiProperty({ minLength: 2, maxLength: 80 })
|
@ApiProperty({ minLength: 2, maxLength: 80 })
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import {
|
import {
|
||||||
decimal,
|
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from './decimal';
|
} from '../common/decimal';
|
||||||
|
import { decimal } from './decimal';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
import { stockPosition } from './stock-position';
|
import { stockPosition } from './stock-position';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsOptional, Matches } from 'class-validator';
|
import { IsDateString, IsOptional, Matches } from 'class-validator';
|
||||||
import { AccountReferenceDto } from './account-reference.dto';
|
import { AccountReferenceDto } from './account-reference.dto';
|
||||||
import { MoneyDto } from './portfolio.dto';
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
|
||||||
export class UpcomingEventsQueryDto {
|
export class UpcomingEventsQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import { RequestContextService } from '../common/logging/request-context.service
|
|||||||
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
decimal,
|
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
isZero,
|
isZero,
|
||||||
multiply,
|
multiply,
|
||||||
} from './decimal';
|
} from '../common/decimal';
|
||||||
|
import { decimal } from './decimal';
|
||||||
import { MoexBondEvent, MoexClient } from './moex.client';
|
import { MoexBondEvent, MoexClient } from './moex.client';
|
||||||
import { MoneyDto } from './portfolio.dto';
|
import { MoneyDto } from '../common/money.dto';
|
||||||
|
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
import {
|
import {
|
||||||
TbankClient,
|
TbankClient,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn, execFileSync } = require('node:child_process');
|
||||||
|
const { mkdtempSync, openSync, closeSync, writeFileSync } = require('node:fs');
|
||||||
|
const { tmpdir } = require('node:os');
|
||||||
|
const { join, resolve } = require('node:path');
|
||||||
|
const { once } = require('node:events');
|
||||||
|
const { createServer } = require('node:net');
|
||||||
|
const { randomUUID } = require('node:crypto');
|
||||||
|
const repo = resolve(__dirname, '..');
|
||||||
|
assert.ok(process.argv[2], 'Pass a separately built baseline repository directory');
|
||||||
|
const baseline = resolve(process.argv[2]);
|
||||||
|
const Database = require(join(repo, 'node_modules/better-sqlite3'));
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-'));
|
||||||
|
let file = join(dir, 'history.db');
|
||||||
|
const env = { ...process.env, DATABASE_URL: `file:${file}`, RUST_LOG: 'info', NODE_ENV: 'development', LOG_LEVEL: 'error', FRONTEND_ORIGIN: 'http://localhost:5189', JWT_SECRET: 'fictional-bank-durability-secret-long-enough', TOKEN_ENCRYPTION_KEY: Buffer.from('0123456789abcdef0123456789abcdef').toString('base64') };
|
||||||
|
let child, origin, run = 0;
|
||||||
|
const credentials = { email: 'durability@example.test', password: 'fictional password long enough' };
|
||||||
|
const rub = value => ({ currency: 'RUB', value });
|
||||||
|
async function start(root) {
|
||||||
|
const server = createServer(); server.listen(0, '127.0.0.1'); await once(server,'listening'); const port=server.address().port; await new Promise(r=>server.close(r));
|
||||||
|
origin=`http://127.0.0.1:${port}`;
|
||||||
|
const log = openSync(join(dir,`app-${++run}.log`),'w');
|
||||||
|
child=spawn(process.execPath,[join(root,'dist/src/main.js')],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`,PORT:String(port)},stdio:['ignore',log,log]}); closeSync(log);
|
||||||
|
for(let i=0;i<150;i++){if(child.exitCode!==null)throw new Error(`app exited ${child.exitCode}`);try{const r=await fetch(`${origin}/api/v1/auth/me`);if(r.status===401)return;}catch{} await new Promise(r=>setTimeout(r,100));}
|
||||||
|
throw new Error('app startup timeout');
|
||||||
|
}
|
||||||
|
async function stop(){ if(!child)return;const c=child; child=null; if(c.exitCode!==null)return; const ended=once(c,'exit');c.kill('SIGTERM');await ended;}
|
||||||
|
async function http(path,method='GET',body,token,status=200){const r=await fetch(`${origin}/api/v1/${path}`,{method,headers:{...(body?{'content-type':'application/json'}:{}),...(token?{Authorization:`Bearer ${token}`}:{})},...(body?{body:JSON.stringify(body)}:{})});const text=await r.text();assert.equal(r.status,status,`${method} ${path}: ${text}`);return text?JSON.parse(text):null;}
|
||||||
|
function migrate(root){execFileSync(process.execPath,[join(root,'node_modules/prisma/build/index.js'),'migrate','deploy'],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`},stdio:['ignore',openSync(join(dir,`migration-${run}.log`),'w'),'pipe']});}
|
||||||
|
function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeIntegers(true);try{return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all();}finally{db.close();}}
|
||||||
|
(async()=>{
|
||||||
|
try{
|
||||||
|
migrate(baseline); await start(baseline);
|
||||||
|
const auth=await http('auth/register','POST',credentials); const owner=auth.user.id;
|
||||||
|
await stop();
|
||||||
|
const db=new Database(file);db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner,'fictional-encrypted-token-never-used',new Date().toISOString());await db.backup(join(dir,'before-migration.db'));db.close();
|
||||||
|
const beforeUsers=rows('User'), beforeSessions=rows('Session');
|
||||||
|
migrate(repo);
|
||||||
|
assert.deepEqual(rows('User'),beforeUsers); assert.deepEqual(rows('Session'),beforeSessions); const migratedDb=new Database(file,{readonly:true});assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');migratedDb.close();
|
||||||
|
await start(repo); let token=(await http('auth/login','POST',credentials)).accessToken;
|
||||||
|
const intent={id:randomUUID(),name:'Долговременная',openingDate:'2026-01-01',openingBalance:rub('1000')};
|
||||||
|
let card=await http('bank-cards','POST',intent,token,201);
|
||||||
|
const category=(await http('bank-cards/categories','GET',null,token)).find(x=>x.isDefault);
|
||||||
|
await http(`bank-cards/categories/${category.id}`,'PATCH',{name:'Сохранённая категория',version:category.version},token);
|
||||||
|
const expenseIntent={id:randomUUID(),kind:'expense',cardId:card.id,amount:rub('100'),date:'2026-08-01'};
|
||||||
|
let expense=await http('bank-cards/operations','POST',expenseIntent,token,201);
|
||||||
|
const refundIntent={id:randomUUID(),kind:'refund',cardId:card.id,originalExpenseId:expense.id,amount:rub('100'),date:'2026-09-01'};
|
||||||
|
const refund=await http('bank-cards/operations','POST',refundIntent,token,201);
|
||||||
|
const deletedIntent={id:randomUUID(),kind:'topup',cardId:card.id,amount:rub('12.34'),date:'2026-09-01'};
|
||||||
|
const deleted=await http('bank-cards/operations','POST',deletedIntent,token,201);
|
||||||
|
await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`,'DELETE',null,token,204);
|
||||||
|
card=await http(`bank-cards/${card.id}`,'GET',null,token);
|
||||||
|
card=await http(`bank-cards/${card.id}`,'PATCH',{version:card.version,archived:true},token);
|
||||||
|
const snapshots={cards:rows('BankCard'),operations:rows('BankCardOperation'),categories:rows('ExpenseCategory')};
|
||||||
|
await stop(); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;
|
||||||
|
assert.deepEqual(await http('bank-cards','POST',intent,token,201),card);
|
||||||
|
assert.deepEqual(await http('bank-cards/operations','POST',refundIntent,token,201),refund);
|
||||||
|
await http('bank-cards/operations','POST',deletedIntent,token,410);
|
||||||
|
assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);
|
||||||
|
await stop();
|
||||||
|
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
|
||||||
|
const latest=new Database(file);await latest.backup(join(dir,'after-history.db'));latest.close();
|
||||||
|
await start(baseline);token=(await http('auth/login','POST',credentials)).accessToken;assert.equal((await http('auth/me','GET',null,token)).id,owner);await stop();
|
||||||
|
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
|
||||||
|
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
|
||||||
|
const restored=join(dir,'restored.db');const backup=new Database(join(dir,'after-history.db'),{readonly:true});await backup.backup(restored);backup.close();file=restored;
|
||||||
|
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
|
||||||
|
assert.deepEqual(rows('User'),beforeUsers);assert.ok(rows('Session').some(s=>s.id===beforeSessions[0].id));
|
||||||
|
const restoredDb=new Database(file,{readonly:true});assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');assert.equal(restoredDb.pragma('integrity_check',{simple:true}),'ok');restoredDb.close();
|
||||||
|
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
|
||||||
|
writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2));
|
||||||
|
console.log(`PASS durability ${dir}`);
|
||||||
|
}finally{await stop();}
|
||||||
|
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { configureApp } from '../src/main';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
BankCardDto,
|
||||||
|
BankOperationDto,
|
||||||
|
BankOperationsPageDto,
|
||||||
|
BankSummaryDto,
|
||||||
|
ExpenseCategoryDto,
|
||||||
|
} from '../src/bank-cards/bank-card.dto';
|
||||||
|
|
||||||
|
jest.setTimeout(60000);
|
||||||
|
describe('Manual bank cards configured HTTP and real SQLite', () => {
|
||||||
|
let app: INestApplication<Server>, db: PrismaService;
|
||||||
|
const owner = randomUUID(),
|
||||||
|
stranger = randomUUID();
|
||||||
|
const token = (user = owner) =>
|
||||||
|
new JwtService().sign(
|
||||||
|
{ sub: user, type: 'access' },
|
||||||
|
{ secret: process.env.JWT_SECRET },
|
||||||
|
);
|
||||||
|
const money = (value: string) => ({ currency: 'RUB', value });
|
||||||
|
const auth = (
|
||||||
|
method: 'get' | 'post' | 'patch' | 'delete',
|
||||||
|
path: string,
|
||||||
|
user = owner,
|
||||||
|
) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
[method]('/api/v1/bank-cards' + path)
|
||||||
|
.set('Authorization', `Bearer ${token(user)}`);
|
||||||
|
const createCard = async (value = '1000', user = owner, extra = {}) =>
|
||||||
|
(
|
||||||
|
await auth('post', '', user)
|
||||||
|
.send({
|
||||||
|
id: randomUUID(),
|
||||||
|
name: 'Вымышленная карта',
|
||||||
|
openingDate: '2026-01-01',
|
||||||
|
openingBalance: money(value),
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
.expect(201)
|
||||||
|
).body as BankCardDto;
|
||||||
|
const getCard = async (id: string) =>
|
||||||
|
(await auth('get', '/' + id).expect(200)).body as BankCardDto;
|
||||||
|
const getOp = async (id: string) =>
|
||||||
|
(await auth('get', '/operations/' + id).expect(200))
|
||||||
|
.body as BankOperationDto;
|
||||||
|
const op = (cardId: string, kind = 'expense', value = '100', extra = {}) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
cardId,
|
||||||
|
kind,
|
||||||
|
amount: money(value),
|
||||||
|
date: '2026-08-01',
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const createOp = async (body: object) =>
|
||||||
|
(await auth('post', '/operations').send(body).expect(201))
|
||||||
|
.body as BankOperationDto;
|
||||||
|
const categories = async () =>
|
||||||
|
(await auth('get', '/categories').expect(200)).body as ExpenseCategoryDto[];
|
||||||
|
const summary = async (month: string, cardId?: string) =>
|
||||||
|
(
|
||||||
|
await auth(
|
||||||
|
'get',
|
||||||
|
`/summary?month=${month}${cardId ? '&cardId=' + cardId : ''}`,
|
||||||
|
).expect(200)
|
||||||
|
).body as BankSummaryDto;
|
||||||
|
const start = async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
app = module.createNestApplication();
|
||||||
|
configureApp(app);
|
||||||
|
await app.init();
|
||||||
|
db = app.get(PrismaService);
|
||||||
|
};
|
||||||
|
beforeAll(async () => {
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (
|
||||||
|
!url ||
|
||||||
|
!/^file:\/(?:private\/)?tmp\/bank-cards-[^/]+\/.+\.db$/.test(url)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
'Pass a unique /tmp/bank-cards-*/test.db DATABASE_URL. No shared/user database is allowed.',
|
||||||
|
);
|
||||||
|
execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
['node_modules/prisma/build/index.js', 'migrate', 'deploy'],
|
||||||
|
{
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: { ...process.env, RUST_LOG: 'info' },
|
||||||
|
stdio: 'pipe',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await start();
|
||||||
|
await db.user.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: owner,
|
||||||
|
email: owner + '@example.test',
|
||||||
|
passwordHash: 'fictional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: stranger,
|
||||||
|
email: stranger + '@example.test',
|
||||||
|
passwordHash: 'fictional',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
it('requires auth and reads never seed data or need T-Bank', async () => {
|
||||||
|
for (const path of ['', '/categories', '/summary?month=2026-08'])
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/bank-cards' + path)
|
||||||
|
.expect(401);
|
||||||
|
expect((await auth('get', '').expect(200)).body).toEqual([]);
|
||||||
|
expect(await categories()).toEqual([]);
|
||||||
|
expect(await db.expenseCategory.count({ where: { userId: owner } })).toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('seeds once concurrently, replays card creation and preserves current archived state', async () => {
|
||||||
|
const body = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name: ' Основная ',
|
||||||
|
openingDate: '2026-01-01',
|
||||||
|
openingBalance: money('1000'),
|
||||||
|
};
|
||||||
|
const responses = await Promise.all([
|
||||||
|
auth('post', '').send(body),
|
||||||
|
auth('post', '').send(body),
|
||||||
|
auth('post', '').send({ ...body, id: randomUUID() }),
|
||||||
|
]);
|
||||||
|
expect(responses.map((r) => r.status)).toEqual([201, 201, 201]);
|
||||||
|
expect(await db.bankCard.count({ where: { userId: owner } })).toBe(2);
|
||||||
|
expect(await categories()).toHaveLength(9);
|
||||||
|
await auth('post', '')
|
||||||
|
.send({ ...body, name: 'Другое имя' })
|
||||||
|
.expect(409);
|
||||||
|
const archived = (
|
||||||
|
await auth('patch', '/' + body.id)
|
||||||
|
.send({ version: 1, archived: true })
|
||||||
|
.expect(200)
|
||||||
|
).body as BankCardDto;
|
||||||
|
const replay = (await auth('post', '').send(body).expect(201))
|
||||||
|
.body as BankCardDto;
|
||||||
|
expect(replay).toEqual(archived);
|
||||||
|
expect(replay.name).toBe('Основная');
|
||||||
|
});
|
||||||
|
it('roundtrips >2^53 and signed64 boundaries, and calculates beyond a stored value', async () => {
|
||||||
|
for (const value of [
|
||||||
|
'90071992547409.93',
|
||||||
|
'92233720368547758.07',
|
||||||
|
'-92233720368547758.08',
|
||||||
|
]) {
|
||||||
|
const card = await createCard(value);
|
||||||
|
expect(card.balance.value).toBe(value);
|
||||||
|
expect((await getCard(card.id)).openingBalance.value).toBe(value);
|
||||||
|
}
|
||||||
|
const card = await createCard('92233720368547758.07');
|
||||||
|
await createOp(op(card.id, 'topup', '0.01'));
|
||||||
|
expect((await getCard(card.id)).balance.value).toBe('92233720368547758.08');
|
||||||
|
const exact = await createCard('1');
|
||||||
|
await createOp(op(exact.id, 'expense', '0.10'));
|
||||||
|
await createOp(op(exact.id, 'expense', '0.20'));
|
||||||
|
expect((await getCard(exact.id)).balance.value).toBe('0.7');
|
||||||
|
await createOp(op(exact.id, 'expense', '1'));
|
||||||
|
expect((await getCard(exact.id)).balance.value).toBe('-0.3');
|
||||||
|
});
|
||||||
|
it('clears optional card metadata and changes opening balance without synthetic operations', async () => {
|
||||||
|
const card = await createCard('0', owner, {
|
||||||
|
bankName: 'Fake Bank',
|
||||||
|
lastFour: '1234',
|
||||||
|
});
|
||||||
|
const changed = (
|
||||||
|
await auth('patch', '/' + card.id)
|
||||||
|
.send({
|
||||||
|
version: 1,
|
||||||
|
bankName: null,
|
||||||
|
lastFour: null,
|
||||||
|
openingBalance: money('-25'),
|
||||||
|
})
|
||||||
|
.expect(200)
|
||||||
|
).body as BankCardDto;
|
||||||
|
expect(changed).toMatchObject({
|
||||||
|
bankName: null,
|
||||||
|
lastFour: null,
|
||||||
|
balance: money('-25'),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await db.bankCardOperation.count({ where: { cardId: card.id } }),
|
||||||
|
).toBe(0);
|
||||||
|
expect((await summary('2026-08', card.id)).net.value).toBe('0');
|
||||||
|
});
|
||||||
|
it('rejects explicit null on nonnullable write fields without changing values or versions', async () => {
|
||||||
|
const card = await createCard();
|
||||||
|
const expense = await createOp(op(card.id));
|
||||||
|
const archived = (
|
||||||
|
await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: card.version, archived: true })
|
||||||
|
.expect(200)
|
||||||
|
).body as BankCardDto;
|
||||||
|
for (const field of ['archived', 'openingBalance', 'openingDate', 'name']) {
|
||||||
|
await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: archived.version, [field]: null })
|
||||||
|
.expect(400);
|
||||||
|
expect(await getCard(card.id)).toEqual(archived);
|
||||||
|
}
|
||||||
|
await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: archived.version, archived: false })
|
||||||
|
.expect(200);
|
||||||
|
for (const field of [
|
||||||
|
'amount',
|
||||||
|
'kind',
|
||||||
|
'cardId',
|
||||||
|
'date',
|
||||||
|
'description',
|
||||||
|
'targetCardId',
|
||||||
|
'categoryId',
|
||||||
|
'originalExpenseId',
|
||||||
|
]) {
|
||||||
|
await auth('patch', '/operations/' + expense.id)
|
||||||
|
.send({ version: expense.version, [field]: null })
|
||||||
|
.expect(400);
|
||||||
|
expect(await getOp(expense.id)).toEqual(expense);
|
||||||
|
}
|
||||||
|
const before = await db.bankCardOperation.count();
|
||||||
|
for (const field of [
|
||||||
|
'amount',
|
||||||
|
'description',
|
||||||
|
'targetCardId',
|
||||||
|
'categoryId',
|
||||||
|
'originalExpenseId',
|
||||||
|
]) {
|
||||||
|
await auth('post', '/operations')
|
||||||
|
.send({ ...op(card.id), [field]: null })
|
||||||
|
.expect(400);
|
||||||
|
}
|
||||||
|
expect(await db.bankCardOperation.count()).toBe(before);
|
||||||
|
});
|
||||||
|
it('validates actual money, calendar, field and operation-shape inputs without writes', async () => {
|
||||||
|
const card = await createCard();
|
||||||
|
const before = await db.bankCardOperation.count();
|
||||||
|
for (const amount of [
|
||||||
|
money('0'),
|
||||||
|
money('-1'),
|
||||||
|
money('1.001'),
|
||||||
|
money('92233720368547758.08'),
|
||||||
|
{ currency: 'USD', value: '1' },
|
||||||
|
money('NaN'),
|
||||||
|
])
|
||||||
|
await auth('post', '/operations')
|
||||||
|
.send({ ...op(card.id), amount })
|
||||||
|
.expect(400);
|
||||||
|
for (const extra of [
|
||||||
|
{ date: '2026-02-30' },
|
||||||
|
{ date: '2099-01-01' },
|
||||||
|
{ date: '2025-12-31' },
|
||||||
|
{ description: 'x'.repeat(501) },
|
||||||
|
{ kind: 'topup', categoryId: (await categories())[0].id },
|
||||||
|
{ kind: 'transfer', targetCardId: card.id },
|
||||||
|
])
|
||||||
|
await auth('post', '/operations')
|
||||||
|
.send(op(card.id, 'expense', '1', extra))
|
||||||
|
.expect(400);
|
||||||
|
expect(await db.bankCardOperation.count()).toBe(before);
|
||||||
|
for (const extra of [
|
||||||
|
{ name: ' ' },
|
||||||
|
{ name: 'x'.repeat(101) },
|
||||||
|
{ bankName: 'x'.repeat(101) },
|
||||||
|
{ lastFour: '123' },
|
||||||
|
{ openingDate: '2026-02-30' },
|
||||||
|
{ openingBalance: money('-92233720368547758.09') },
|
||||||
|
])
|
||||||
|
await auth('post', '')
|
||||||
|
.send({
|
||||||
|
id: randomUUID(),
|
||||||
|
name: 'Card',
|
||||||
|
openingDate: '2026-01-01',
|
||||||
|
openingBalance: money('0'),
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
it('scopes every card/category/expense link and edit to owner', async () => {
|
||||||
|
const own = await createCard(),
|
||||||
|
foreign = await createCard('0', stranger);
|
||||||
|
const foreignCategories = (
|
||||||
|
await auth('get', '/categories', stranger).expect(200)
|
||||||
|
).body as ExpenseCategoryDto[];
|
||||||
|
const foreignOp = (
|
||||||
|
await auth('post', '/operations', stranger)
|
||||||
|
.send(op(foreign.id))
|
||||||
|
.expect(201)
|
||||||
|
).body as BankOperationDto;
|
||||||
|
for (const path of [
|
||||||
|
'/' + foreign.id,
|
||||||
|
'/' + foreign.id + '/operations?month=2026-08',
|
||||||
|
'/' + foreign.id + '/expenses',
|
||||||
|
'/operations/' + foreignOp.id,
|
||||||
|
'/summary?month=2026-08&cardId=' + foreign.id,
|
||||||
|
])
|
||||||
|
await auth('get', path).expect(404);
|
||||||
|
for (const body of [
|
||||||
|
op(foreign.id),
|
||||||
|
op(own.id, 'expense', '1', { categoryId: foreignCategories[0].id }),
|
||||||
|
op(own.id, 'transfer', '1', { targetCardId: foreign.id }),
|
||||||
|
op(own.id, 'refund', '1', { originalExpenseId: foreignOp.id }),
|
||||||
|
])
|
||||||
|
await auth('post', '/operations').send(body).expect(404);
|
||||||
|
await auth('patch', '/' + foreign.id)
|
||||||
|
.send({ version: 1, archived: true })
|
||||||
|
.expect(404);
|
||||||
|
await auth('patch', '/categories/' + foreignCategories[0].id)
|
||||||
|
.send({ version: 1, name: 'No' })
|
||||||
|
.expect(404);
|
||||||
|
await auth('patch', '/operations/' + foreignOp.id)
|
||||||
|
.send({ version: 1, amount: money('2') })
|
||||||
|
.expect(404);
|
||||||
|
await auth('delete', '/operations/' + foreignOp.id + '?version=1').expect(
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
expect((await getCard(own.id)).balance.value).toBe('1000');
|
||||||
|
});
|
||||||
|
it('renames default category without role loss; duplicates and stale rename conflict', async () => {
|
||||||
|
const category = (await categories()).find((c) => c.isDefault)!;
|
||||||
|
await auth('patch', '/categories/' + category.id)
|
||||||
|
.send({ version: category.version, name: 'Остальное' })
|
||||||
|
.expect(200);
|
||||||
|
await auth('patch', '/categories/' + category.id)
|
||||||
|
.send({ version: category.version, name: 'Stale' })
|
||||||
|
.expect(409);
|
||||||
|
await auth('post', '/categories').send({ name: ' кафе ' }).expect(409);
|
||||||
|
const custom = (
|
||||||
|
await auth('post', '/categories').send({ name: 'Питомец' }).expect(201)
|
||||||
|
).body as ExpenseCategoryDto;
|
||||||
|
expect(
|
||||||
|
(await categories()).find((c) => c.id === category.id),
|
||||||
|
).toMatchObject({ name: 'Остальное', isDefault: true });
|
||||||
|
const card = await createCard();
|
||||||
|
expect((await createOp(op(card.id))).categoryId).toBe(category.id);
|
||||||
|
expect(custom.name).toBe('Питомец');
|
||||||
|
});
|
||||||
|
it('keeps both independent expenses, exactly one same-intent operation and tombstone after editing/deleting', async () => {
|
||||||
|
const card = await createCard();
|
||||||
|
const body = op(card.id);
|
||||||
|
const responses = await Promise.all([
|
||||||
|
auth('post', '/operations').send(body),
|
||||||
|
auth('post', '/operations').send(body),
|
||||||
|
auth('post', '/operations').send(op(card.id, 'expense', '200')),
|
||||||
|
]);
|
||||||
|
expect(responses.map((r) => r.status)).toEqual([201, 201, 201]);
|
||||||
|
expect((await getCard(card.id)).balance.value).toBe('700');
|
||||||
|
await auth('post', '/operations')
|
||||||
|
.send({ ...body, amount: money('200') })
|
||||||
|
.expect(409);
|
||||||
|
const edited = (
|
||||||
|
await auth('patch', '/operations/' + body.id)
|
||||||
|
.send({ version: 1, amount: money('150') })
|
||||||
|
.expect(200)
|
||||||
|
).body as BankOperationDto;
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
(await auth('post', '/operations').send(body).expect(201))
|
||||||
|
.body as BankOperationDto
|
||||||
|
).amount.value,
|
||||||
|
).toBe('150');
|
||||||
|
await auth('delete', '/operations/' + body.id + '?version=1').expect(409);
|
||||||
|
await auth(
|
||||||
|
'delete',
|
||||||
|
`/operations/${body.id}?version=${edited.version}`,
|
||||||
|
).expect(204);
|
||||||
|
await auth('delete', '/operations/' + body.id + '?version=1').expect(204);
|
||||||
|
await auth('post', '/operations').send(body).expect(410);
|
||||||
|
expect((await getCard(card.id)).balance.value).toBe('800');
|
||||||
|
});
|
||||||
|
it('transfers atomically, edits both sides, validates old and new cards and opening dates', async () => {
|
||||||
|
const a = await createCard('100'),
|
||||||
|
b = await createCard('0'),
|
||||||
|
late = await createCard('0', owner, { openingDate: '2026-09-01' });
|
||||||
|
await auth('post', '/operations')
|
||||||
|
.send(op(a.id, 'transfer', '150', { targetCardId: late.id }))
|
||||||
|
.expect(400);
|
||||||
|
const transfer = await createOp(
|
||||||
|
op(a.id, 'transfer', '150', { targetCardId: b.id }),
|
||||||
|
);
|
||||||
|
expect((await getCard(a.id)).balance.value).toBe('-50');
|
||||||
|
expect((await getCard(b.id)).balance.value).toBe('150');
|
||||||
|
const history = (
|
||||||
|
await auth('get', `/${b.id}/operations?month=2026-08`).expect(200)
|
||||||
|
).body as BankOperationsPageDto;
|
||||||
|
expect(history.items[0]).toMatchObject({
|
||||||
|
id: transfer.id,
|
||||||
|
cardId: a.id,
|
||||||
|
targetCardId: b.id,
|
||||||
|
});
|
||||||
|
expect((await summary('2026-08', b.id)).expenses.value).toBe('0');
|
||||||
|
await auth('patch', '/' + b.id)
|
||||||
|
.send({ version: 1, openingDate: '2026-08-02' })
|
||||||
|
.expect(400);
|
||||||
|
const edited = (
|
||||||
|
await auth('patch', '/operations/' + transfer.id)
|
||||||
|
.send({ version: 1, amount: money('50') })
|
||||||
|
.expect(200)
|
||||||
|
).body as BankOperationDto;
|
||||||
|
expect((await getCard(a.id)).balance.value).toBe('50');
|
||||||
|
expect((await getCard(b.id)).balance.value).toBe('50');
|
||||||
|
await auth(
|
||||||
|
'delete',
|
||||||
|
`/operations/${transfer.id}?version=${edited.version}`,
|
||||||
|
).expect(204);
|
||||||
|
expect((await getCard(a.id)).balance.value).toBe('100');
|
||||||
|
expect((await getCard(b.id)).balance.value).toBe('0');
|
||||||
|
});
|
||||||
|
it('serializes refund limits, replays full refund before limit checks, excludes own value during edit', async () => {
|
||||||
|
const card = await createCard();
|
||||||
|
const expense = await createOp(op(card.id, 'expense', '1000'));
|
||||||
|
const responses = await Promise.all([
|
||||||
|
auth('post', '/operations').send(
|
||||||
|
op(card.id, 'refund', '600', {
|
||||||
|
date: '2026-09-01',
|
||||||
|
originalExpenseId: expense.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
auth('post', '/operations').send(
|
||||||
|
op(card.id, 'refund', '600', {
|
||||||
|
date: '2026-09-01',
|
||||||
|
originalExpenseId: expense.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
expect(responses.map((r) => r.status).sort()).toEqual([201, 409]);
|
||||||
|
const body = op(card.id, 'refund', '400', {
|
||||||
|
date: '2026-09-01',
|
||||||
|
originalExpenseId: expense.id,
|
||||||
|
});
|
||||||
|
const refund = await createOp(body);
|
||||||
|
expect(refund.availableRefund?.value).toBe('400');
|
||||||
|
await auth('post', '/operations').send(body).expect(201);
|
||||||
|
await auth('patch', '/operations/' + refund.id)
|
||||||
|
.send({ version: refund.version, description: 'Тот же полный возврат' })
|
||||||
|
.expect(200);
|
||||||
|
const sept = await summary('2026-09', card.id);
|
||||||
|
expect(sept).toMatchObject({
|
||||||
|
expenses: money('0'),
|
||||||
|
refunds: money('1000'),
|
||||||
|
net: money('-1000'),
|
||||||
|
});
|
||||||
|
const august = await summary('2026-08', card.id);
|
||||||
|
expect(august.net.value).toBe('1000');
|
||||||
|
const choices = (await auth('get', '/' + card.id + '/expenses').expect(200))
|
||||||
|
.body as BankOperationsPageDto;
|
||||||
|
expect(choices.items[0].availableRefund?.value).toBe('0');
|
||||||
|
const current = await getOp(expense.id);
|
||||||
|
for (const patch of [
|
||||||
|
{ amount: money('999') },
|
||||||
|
{ date: '2026-09-02' },
|
||||||
|
{ cardId: (await createCard()).id },
|
||||||
|
])
|
||||||
|
await auth('patch', '/operations/' + expense.id)
|
||||||
|
.send({ version: current.version, ...patch })
|
||||||
|
.expect(409);
|
||||||
|
await auth(
|
||||||
|
'delete',
|
||||||
|
`/operations/${expense.id}?version=${current.version}`,
|
||||||
|
).expect(409);
|
||||||
|
const category = (await categories()).find((c) => !c.isDefault)!;
|
||||||
|
await auth('patch', '/operations/' + expense.id)
|
||||||
|
.send({ version: current.version, categoryId: category.id })
|
||||||
|
.expect(200);
|
||||||
|
expect((await summary('2026-09', card.id)).categories[0].categoryId).toBe(
|
||||||
|
category.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('rejects invalid refund references, dates, immutable kind and purchase link', async () => {
|
||||||
|
const card = await createCard(),
|
||||||
|
other = await createCard();
|
||||||
|
const purchase = await createOp(op(card.id)),
|
||||||
|
topup = await createOp(op(card.id, 'topup'));
|
||||||
|
for (const body of [
|
||||||
|
op(card.id, 'refund', '1', { originalExpenseId: topup.id }),
|
||||||
|
op(other.id, 'refund', '1', { originalExpenseId: purchase.id }),
|
||||||
|
op(card.id, 'refund', '1', {
|
||||||
|
originalExpenseId: purchase.id,
|
||||||
|
date: '2026-07-01',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
await auth('post', '/operations').send(body).expect(400);
|
||||||
|
const refund = await createOp(
|
||||||
|
op(card.id, 'refund', '10', { originalExpenseId: purchase.id }),
|
||||||
|
);
|
||||||
|
await auth('patch', '/operations/' + refund.id)
|
||||||
|
.send({ version: refund.version, originalExpenseId: topup.id })
|
||||||
|
.expect(400);
|
||||||
|
await auth('patch', '/operations/' + topup.id)
|
||||||
|
.send({ version: topup.version, kind: 'expense' })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
it('moves plain operations and month totals, preserves full balance with stable pagination', async () => {
|
||||||
|
const a = await createCard(),
|
||||||
|
b = await createCard();
|
||||||
|
const first = await createOp(op(a.id, 'expense', '10'));
|
||||||
|
await createOp(op(a.id, 'expense', '20'));
|
||||||
|
await createOp(op(a.id, 'expense', '30'));
|
||||||
|
const pages: BankOperationsPageDto[] = [];
|
||||||
|
for (let page = 1; page <= 3; page++)
|
||||||
|
pages.push(
|
||||||
|
(
|
||||||
|
await auth(
|
||||||
|
'get',
|
||||||
|
`/${a.id}/operations?month=2026-08&page=${page}&limit=1`,
|
||||||
|
).expect(200)
|
||||||
|
).body as BankOperationsPageDto,
|
||||||
|
);
|
||||||
|
expect(new Set(pages.flatMap((p) => p.items.map((i) => i.id))).size).toBe(
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
expect(pages.map((p) => p.hasNext)).toEqual([true, true, false]);
|
||||||
|
expect((await getCard(a.id)).balance.value).toBe('940');
|
||||||
|
expect((await summary('2026-08', a.id)).expenses.value).toBe('60');
|
||||||
|
await auth('patch', '/operations/' + first.id)
|
||||||
|
.send({ version: first.version, cardId: b.id, date: '2026-09-01' })
|
||||||
|
.expect(200);
|
||||||
|
expect((await getCard(a.id)).balance.value).toBe('950');
|
||||||
|
expect((await getCard(b.id)).balance.value).toBe('990');
|
||||||
|
expect((await summary('2026-09', b.id)).net.value).toBe('10');
|
||||||
|
});
|
||||||
|
it('serializes archive with writes, blocks financial changes, keeps metadata editable and all-card history', async () => {
|
||||||
|
const card = await createCard();
|
||||||
|
const expense = await createOp(op(card.id));
|
||||||
|
const total = (await summary('2026-08')).net.value;
|
||||||
|
await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: 1, archived: true })
|
||||||
|
.expect(200);
|
||||||
|
for (const body of [
|
||||||
|
op(card.id),
|
||||||
|
op(card.id, 'transfer', '1', { targetCardId: (await createCard()).id }),
|
||||||
|
])
|
||||||
|
await auth('post', '/operations').send(body).expect(409);
|
||||||
|
await auth('patch', '/operations/' + expense.id)
|
||||||
|
.send({ version: 1, description: 'no' })
|
||||||
|
.expect(409);
|
||||||
|
await auth('delete', `/operations/${expense.id}?version=1`).expect(409);
|
||||||
|
await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: 2, openingBalance: money('200') })
|
||||||
|
.expect(409);
|
||||||
|
await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: 2, name: 'Архивная' })
|
||||||
|
.expect(200);
|
||||||
|
expect((await summary('2026-08')).net.value).toBe(total);
|
||||||
|
await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: 1, archived: false })
|
||||||
|
.expect(409);
|
||||||
|
await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: 3, archived: false })
|
||||||
|
.expect(200);
|
||||||
|
await createOp(op(card.id));
|
||||||
|
const racing = await createCard();
|
||||||
|
const results = await Promise.all([
|
||||||
|
auth('patch', '/' + racing.id).send({ version: 1, archived: true }),
|
||||||
|
auth('post', '/operations').send(op(racing.id)),
|
||||||
|
]);
|
||||||
|
expect(results[0].status).toBe(200);
|
||||||
|
expect([201, 409]).toContain(results[1].status);
|
||||||
|
expect((await getCard(racing.id)).balance.value).toBe(
|
||||||
|
results[1].status === 201 ? '900' : '1000',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('serializes a refund against reducing its purchase, and rolls back rejected source locks', async () => {
|
||||||
|
const card = await createCard();
|
||||||
|
const expense = await createOp(op(card.id, 'expense', '1000'));
|
||||||
|
const results = await Promise.all([
|
||||||
|
auth('post', '/operations').send(
|
||||||
|
op(card.id, 'refund', '600', { originalExpenseId: expense.id }),
|
||||||
|
),
|
||||||
|
auth('patch', '/operations/' + expense.id).send({
|
||||||
|
version: expense.version,
|
||||||
|
amount: money('500'),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect([201, 409]).toContain(results[0].status);
|
||||||
|
expect([200, 409]).toContain(results[1].status);
|
||||||
|
expect(results.filter((r) => r.status < 300)).toHaveLength(1);
|
||||||
|
const current = await getOp(expense.id);
|
||||||
|
const invalid = op(card.id, 'refund', '1', {
|
||||||
|
originalExpenseId: expense.id,
|
||||||
|
date: '2026-07-01',
|
||||||
|
});
|
||||||
|
await auth('post', '/operations').send(invalid).expect(400);
|
||||||
|
expect((await getOp(expense.id)).version).toBe(current.version);
|
||||||
|
const refunds = await db.bankCardOperation.findMany({
|
||||||
|
where: { originalExpenseId: expense.id, deletedAt: null },
|
||||||
|
});
|
||||||
|
const source = await db.bankCardOperation.findUniqueOrThrow({
|
||||||
|
where: { id: expense.id },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) <=
|
||||||
|
source.amountKopecks,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
it('serializes opening-date changes and validates archived previous transfer sides', async () => {
|
||||||
|
const card = await createCard();
|
||||||
|
const results = await Promise.all([
|
||||||
|
auth('patch', '/' + card.id).send({
|
||||||
|
version: 1,
|
||||||
|
openingDate: '2026-09-01',
|
||||||
|
}),
|
||||||
|
auth('post', '/operations').send(op(card.id)),
|
||||||
|
]);
|
||||||
|
expect(results.filter((r) => r.status < 300)).toHaveLength(1);
|
||||||
|
const current = await getCard(card.id);
|
||||||
|
const rows = await db.bankCardOperation.findMany({
|
||||||
|
where: { cardId: card.id, deletedAt: null },
|
||||||
|
});
|
||||||
|
expect(rows.every((row) => row.date >= current.openingDate)).toBe(true);
|
||||||
|
const a = await createCard(),
|
||||||
|
b = await createCard(),
|
||||||
|
c = await createCard();
|
||||||
|
const transfer = await createOp(
|
||||||
|
op(a.id, 'transfer', '10', { targetCardId: b.id }),
|
||||||
|
);
|
||||||
|
await auth('patch', '/' + b.id)
|
||||||
|
.send({ version: 1, archived: true })
|
||||||
|
.expect(200);
|
||||||
|
await auth('patch', '/operations/' + transfer.id)
|
||||||
|
.send({ version: 1, targetCardId: c.id })
|
||||||
|
.expect(409);
|
||||||
|
expect((await getCard(b.id)).balance.value).toBe('1010');
|
||||||
|
expect((await getCard(c.id)).balance.value).toBe('1000');
|
||||||
|
});
|
||||||
|
it('deleting refunds releases the purchase and a later replay never restores either record', async () => {
|
||||||
|
const card = await createCard();
|
||||||
|
const purchaseBody = op(card.id),
|
||||||
|
purchase = await createOp(purchaseBody);
|
||||||
|
const refundBody = op(card.id, 'refund', '50', {
|
||||||
|
originalExpenseId: purchase.id,
|
||||||
|
}),
|
||||||
|
refund = await createOp(refundBody);
|
||||||
|
await auth(
|
||||||
|
'delete',
|
||||||
|
`/operations/${refund.id}?version=${refund.version}`,
|
||||||
|
).expect(204);
|
||||||
|
const current = await getOp(purchase.id);
|
||||||
|
expect(current.availableRefund?.value).toBe('100');
|
||||||
|
await auth(
|
||||||
|
'delete',
|
||||||
|
`/operations/${purchase.id}?version=${current.version}`,
|
||||||
|
).expect(204);
|
||||||
|
await auth('post', '/operations').send(refundBody).expect(410);
|
||||||
|
await auth('post', '/operations').send(purchaseBody).expect(410);
|
||||||
|
await auth('post', '/operations')
|
||||||
|
.send(op(card.id, 'refund', '1', { originalExpenseId: purchase.id }))
|
||||||
|
.expect(400);
|
||||||
|
expect((await getCard(card.id)).balance.value).toBe('1000');
|
||||||
|
});
|
||||||
|
it('preserves history, fingerprints, archive and deleted replay across app restart', async () => {
|
||||||
|
const card = await createCard();
|
||||||
|
const purchase = await createOp(op(card.id));
|
||||||
|
const refund = await createOp(
|
||||||
|
op(card.id, 'refund', '10', { originalExpenseId: purchase.id }),
|
||||||
|
);
|
||||||
|
const deletedBody = op(card.id, 'topup', '20');
|
||||||
|
const deleted = await createOp(deletedBody);
|
||||||
|
await auth('delete', `/operations/${deleted.id}?version=1`).expect(204);
|
||||||
|
await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: 1, archived: true })
|
||||||
|
.expect(200);
|
||||||
|
const before = await getCard(card.id);
|
||||||
|
await app.close();
|
||||||
|
await start();
|
||||||
|
expect(await getCard(card.id)).toEqual(before);
|
||||||
|
expect((await getOp(refund.id)).originalExpenseId).toBe(purchase.id);
|
||||||
|
await auth('post', '/operations').send(deletedBody).expect(410);
|
||||||
|
expect((await categories()).find((c) => c.isDefault)?.name).toBe(
|
||||||
|
'Остальное',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('enforces actual FK and unique constraints', async () => {
|
||||||
|
await expect(
|
||||||
|
db.expenseCategory.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: randomUUID(),
|
||||||
|
name: 'Invalid',
|
||||||
|
nameKey: 'invalid',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'P2003' });
|
||||||
|
const category = (await categories())[0];
|
||||||
|
await expect(
|
||||||
|
db.expenseCategory.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: owner,
|
||||||
|
name: category.name,
|
||||||
|
nameKey: category.name.toLocaleLowerCase('ru-RU'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'P2002' });
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
process.env.DATABASE_URL = 'file:/tmp/wealth-ledger-e2e.db';
|
process.env.DATABASE_URL ??= 'file:/tmp/wealth-ledger-e2e.db';
|
||||||
process.env.FRONTEND_ORIGIN = 'http://localhost:5173';
|
process.env.FRONTEND_ORIGIN = 'http://localhost:5173';
|
||||||
process.env.JWT_SECRET = 'e2e-secret-that-is-at-least-thirty-two-characters';
|
process.env.JWT_SECRET = 'e2e-secret-that-is-at-least-thirty-two-characters';
|
||||||
process.env.TOKEN_ENCRYPTION_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
process.env.TOKEN_ENCRYPTION_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
||||||
|
|||||||
Reference in New Issue
Block a user